mirror of
https://github.com/t8y2/dbx.git
synced 2026-11-15 08:27:46 +08:00
feat(update): add beta and stable release channels
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const workflow = readFileSync(new URL("../workflows/daily-beta-release.yml", import.meta.url), "utf8");
|
||||
const script = workflow.match(/ run: \|\n((?: [^\n]*\n|\n)*)/)[1].replace(/^ {10}/gm, "");
|
||||
|
||||
function runDaily(options = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "dbx-daily-beta-test-"));
|
||||
const statePath = join(root, "state.json");
|
||||
const gh = join(root, "gh");
|
||||
writeFileSync(statePath, JSON.stringify({ calls: [] }));
|
||||
writeFileSync(gh, `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const args = process.argv.slice(2);
|
||||
const state = JSON.parse(fs.readFileSync(process.env.MOCK_GH_STATE, "utf8"));
|
||||
state.calls.push(args);
|
||||
const save = () => fs.writeFileSync(process.env.MOCK_GH_STATE, JSON.stringify(state));
|
||||
save();
|
||||
if (args[0] === "release" && args[1] === "view") {
|
||||
if (args[2] === "--repo") { console.log("v0.6.59"); process.exit(0); }
|
||||
process.exit(1);
|
||||
}
|
||||
if (args[0] === "api" && args[1]?.includes("/git/ref/tags/")) {
|
||||
process.exit(process.env.MOCK_ORPHAN_TAG && args[1].endsWith(".1") ? 0 : 1);
|
||||
}
|
||||
if (args[0] === "api" && args[1] === "--method" && args[2] === "POST") {
|
||||
state.tag = args.find(arg => arg.startsWith("ref=refs/tags/"))?.slice("ref=refs/tags/".length);
|
||||
state.sha = args.find(arg => arg.startsWith("sha="))?.slice(4);
|
||||
save(); process.exit(state.tag && state.sha === process.env.GITHUB_SHA ? 0 : 1);
|
||||
}
|
||||
if (args[0] === "release" && args[1] === "create") {
|
||||
if (!state.tag || args[2] !== state.tag || !args.includes("--verify-tag")) process.exit(1);
|
||||
state.release = args[2]; save(); process.exit(0);
|
||||
}
|
||||
if (args[0] === "workflow" && args[1] === "run") {
|
||||
const ref = args[args.indexOf("--ref") + 1];
|
||||
if (!state.release || ref !== state.tag || !args.includes("release_tag=" + ref)) process.exit(1);
|
||||
if (process.env.GH_TOKEN !== "test-dispatch-token") process.exit(1);
|
||||
state.dispatched = ref; save(); process.exit(0);
|
||||
}
|
||||
process.exit(1);
|
||||
`);
|
||||
chmodSync(gh, 0o755);
|
||||
const result = spawnSync("bash", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${root}:${process.env.PATH}`,
|
||||
MOCK_GH_STATE: statePath,
|
||||
GITHUB_REPOSITORY: "example/dbx",
|
||||
GITHUB_SHA: "a".repeat(40),
|
||||
REQUESTED_BASE_VERSION: "0.6.60",
|
||||
FORCE_BUILD: "",
|
||||
GH_TOKEN: "test-release-token",
|
||||
RELEASE_WORKFLOW_TOKEN: "test-dispatch-token",
|
||||
...options,
|
||||
},
|
||||
});
|
||||
return { ...result, state: JSON.parse(readFileSync(statePath, "utf8")) };
|
||||
}
|
||||
|
||||
test("daily beta creates a real tag before creating the draft and dispatching its build", () => {
|
||||
const result = runDaily();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.state.dispatched, /^v0\.6\.60-beta\.\d{8}\.1$/);
|
||||
assert.equal(result.state.sha, "a".repeat(40));
|
||||
});
|
||||
|
||||
test("daily beta skips a tag left behind without a release", () => {
|
||||
const result = runDaily({ MOCK_ORPHAN_TAG: "1" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.state.dispatched, /^v0\.6\.60-beta\.\d{8}\.2$/);
|
||||
});
|
||||
|
||||
test("daily beta refuses to create refs when the workflow dispatch token is absent", () => {
|
||||
const result = runDaily({ RELEASE_WORKFLOW_TOKEN: "" });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stdout, /MCP_RELEASE_TOKEN is required/);
|
||||
assert.equal(result.state.tag, undefined);
|
||||
assert.equal(result.state.release, undefined);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const [input, output, channel = "stable", tag = "", promoted = "false", publishedAt = ""] = process.argv.slice(2);
|
||||
const tagMatch = /^v(\d+\.\d+\.\d+)(-beta\.\d{8}\.\d+)?$/.exec(tag);
|
||||
if (
|
||||
!input ||
|
||||
!output ||
|
||||
!["stable", "beta"].includes(channel) ||
|
||||
!tagMatch ||
|
||||
!["true", "false"].includes(promoted)
|
||||
) {
|
||||
console.error("Usage: prepare-channel-manifest.mjs <input> <output> <stable|beta> <vX.Y.Z[ -beta.YYYYMMDD.N]> [true|false] [published_at]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (channel === "beta" && !tagMatch[2]) {
|
||||
throw new Error(`Beta manifests require a beta tag: ${tag}`);
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync(input, "utf8"));
|
||||
if (!manifest || typeof manifest !== "object" || !manifest.version || !manifest.platforms || typeof manifest.platforms !== "object") {
|
||||
throw new Error("Updater manifest must contain version and platforms.");
|
||||
}
|
||||
|
||||
const baseVersion = tagMatch[1];
|
||||
const tagVersion = tag.slice(1);
|
||||
if (manifest.version !== baseVersion && manifest.version !== tagVersion) {
|
||||
throw new Error(`Manifest version ${manifest.version} does not match tag version ${tagVersion} or base version ${baseVersion}.`);
|
||||
}
|
||||
manifest.version = channel === "beta" ? tagVersion : baseVersion;
|
||||
manifest.channel = channel;
|
||||
manifest.release_tag = tag;
|
||||
manifest.is_promoted = promoted === "true";
|
||||
manifest.published_at ||= publishedAt || new Date().toISOString();
|
||||
writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = fileURLToPath(new URL("./prepare-channel-manifest.mjs", import.meta.url));
|
||||
|
||||
async function run(args) {
|
||||
const child = spawn(process.execPath, [script, ...args], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
const code = await new Promise((resolve) => child.on("close", resolve));
|
||||
return { code, stderr };
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), "dbx-channel-manifest-"));
|
||||
const input = join(root, "input.json");
|
||||
const output = join(root, "output.json");
|
||||
await writeFile(input, JSON.stringify({ version: "0.6.60", platforms: { "darwin-aarch64": {} } }));
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
test("writes channel and promotion metadata", async () => {
|
||||
const { input, output } = await fixture();
|
||||
const result = await run([input, output, "beta", "v0.6.60-beta.20261028.1", "false", "2026-10-28T00:00:00.000Z"]);
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(await readFile(output, "utf8")), {
|
||||
version: "0.6.60-beta.20261028.1",
|
||||
platforms: { "darwin-aarch64": {} },
|
||||
channel: "beta",
|
||||
release_tag: "v0.6.60-beta.20261028.1",
|
||||
is_promoted: false,
|
||||
published_at: "2026-10-28T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
test("promotes a beta manifest back to the stable base version", async () => {
|
||||
const { input, output } = await fixture();
|
||||
await writeFile(input, JSON.stringify({ version: "0.6.60-beta.20261028.1", platforms: {} }));
|
||||
const result = await run([input, output, "stable", "v0.6.60-beta.20261028.1", "true", "2026-10-28T00:00:00.000Z"]);
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.equal(JSON.parse(await readFile(output, "utf8")).version, "0.6.60");
|
||||
});
|
||||
|
||||
test("preserves an existing published_at", async () => {
|
||||
const { input, output } = await fixture();
|
||||
await writeFile(input, JSON.stringify({ version: "0.6.60", platforms: {}, published_at: "2026-10-27T00:00:00.000Z" }));
|
||||
const result = await run([input, output, "stable", "v0.6.60", "true", "2026-10-28T00:00:00.000Z"]);
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.equal(JSON.parse(await readFile(output, "utf8")).published_at, "2026-10-27T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("fixed published_at makes repeated promotion deterministic", async () => {
|
||||
const first = await fixture();
|
||||
const second = await fixture();
|
||||
const args = ["stable", "v0.6.60", "true", "2026-10-28T00:00:00.000Z"];
|
||||
assert.equal((await run([first.input, first.output, ...args])).code, 0);
|
||||
assert.equal((await run([second.input, second.output, ...args])).code, 0);
|
||||
assert.equal(await readFile(first.output, "utf8"), await readFile(second.output, "utf8"));
|
||||
});
|
||||
|
||||
test("rejects invalid channel, tag, and promotion flag", async () => {
|
||||
const { input, output } = await fixture();
|
||||
for (const args of [
|
||||
["canary", "v0.6.60", "false"],
|
||||
["stable", "0.6.60", "true"],
|
||||
["stable", "v0.6.60", "yes"],
|
||||
["beta", "v0.6.60", "false"],
|
||||
]) {
|
||||
const result = await run([input, output, ...args]);
|
||||
assert.notEqual(result.code, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a tag whose base version differs from the built manifest", async () => {
|
||||
const { input, output } = await fixture();
|
||||
const result = await run([input, output, "beta", "v0.6.61-beta.20261028.1", "false"]);
|
||||
assert.notEqual(result.code, 0);
|
||||
});
|
||||
|
||||
test("rejects a beta manifest with a stable tag", async () => {
|
||||
const { input, output } = await fixture();
|
||||
const result = await run([input, output, "beta", "v0.6.60", "false"]);
|
||||
assert.notEqual(result.code, 0);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// WiX/MSI only accepts numeric `major.minor.patch.build` versions, and the Tauri
|
||||
// bundler rejects a pre-release such as `-beta.20261029.1`. Beta builds keep the
|
||||
// full semver for the app and updater; only the MSI gets this numeric version.
|
||||
// Windows Installer ignores the fourth field when comparing versions, so it is
|
||||
// informational: day-of-year * 100 + the daily build sequence (max 36699).
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const betaTag = /^v(\d+)\.(\d+)\.(\d+)-beta\.(\d{4})(\d{2})(\d{2})\.(\d+)$/;
|
||||
const stableTag = /^v\d+\.\d+\.\d+$/;
|
||||
|
||||
export function msiConfigForTag(tag) {
|
||||
if (stableTag.test(tag)) return {};
|
||||
const match = betaTag.exec(tag);
|
||||
if (!match) throw new Error(`Unsupported release tag for MSI version: ${tag}`);
|
||||
|
||||
const [major, minor, patch, year, month, day, sequence] = match.slice(1).map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
|
||||
throw new Error(`Invalid beta build date in ${tag}.`);
|
||||
}
|
||||
if (major > 255 || minor > 255 || patch > 65535) {
|
||||
throw new Error(`MSI version limits (255.255.65535) exceeded by ${tag}.`);
|
||||
}
|
||||
if (sequence < 1 || sequence > 99) {
|
||||
throw new Error(`Beta build sequence must be 1-99 to fit the MSI build field: ${tag}.`);
|
||||
}
|
||||
|
||||
const dayOfYear = Math.round((date - Date.UTC(year, 0, 1)) / 86_400_000) + 1;
|
||||
const build = dayOfYear * 100 + sequence;
|
||||
return { bundle: { windows: { wix: { version: `${major}.${minor}.${patch}.${build}` } } } };
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const [tag, output] = process.argv.slice(2);
|
||||
if (!tag || !output) {
|
||||
console.error("Usage: prepare-msi-config.mjs <release_tag> <output.json>");
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const config = msiConfigForTag(tag);
|
||||
writeFileSync(output, `${JSON.stringify(config, null, 2)}\n`);
|
||||
console.log(`Wrote ${output}: ${JSON.stringify(config)}`);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
import { msiConfigForTag } from "./prepare-msi-config.mjs";
|
||||
|
||||
const script = fileURLToPath(new URL("./prepare-msi-config.mjs", import.meta.url));
|
||||
|
||||
test("stable tags leave the MSI version to the app version", () => {
|
||||
assert.deepEqual(msiConfigForTag("v0.6.60"), {});
|
||||
});
|
||||
|
||||
test("beta tags get a numeric MSI version derived from the build date and sequence", () => {
|
||||
const wixVersion = (tag) => msiConfigForTag(tag).bundle.windows.wix.version;
|
||||
assert.equal(wixVersion("v0.6.60-beta.20261029.1"), "0.6.60.30201");
|
||||
assert.equal(wixVersion("v0.6.60-beta.20260101.1"), "0.6.60.101");
|
||||
assert.equal(wixVersion("v0.6.60-beta.20261231.99"), "0.6.60.36599");
|
||||
assert.equal(wixVersion("v0.6.60-beta.20240229.2"), "0.6.60.6002");
|
||||
});
|
||||
|
||||
test("every beta MSI build number fits the 65535 field limit", () => {
|
||||
const { version } = msiConfigForTag("v255.255.65535-beta.20261231.99").bundle.windows.wix;
|
||||
assert.ok(Number(version.split(".")[3]) <= 65535);
|
||||
});
|
||||
|
||||
test("rejects tags the MSI version cannot represent", () => {
|
||||
assert.throws(() => msiConfigForTag("v0.6.60-beta.20261029.100"), /1-99/);
|
||||
assert.throws(() => msiConfigForTag("v0.6.60-beta.20261029.0"), /1-99/);
|
||||
assert.throws(() => msiConfigForTag("v0.6.60-beta.20261329.1"), /Invalid beta build date/);
|
||||
assert.throws(() => msiConfigForTag("v256.0.0-beta.20261029.1"), /limits/);
|
||||
assert.throws(() => msiConfigForTag("v0.6.60-rc.1"), /Unsupported release tag/);
|
||||
});
|
||||
|
||||
test("writes the config file for the Tauri CLI", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "dbx-msi-config-"));
|
||||
const output = join(dir, "tauri.msi.conf.json");
|
||||
const result = spawnSync(process.execPath, [script, "v0.6.60-beta.20261029.1", output], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(await readFile(output, "utf8")), {
|
||||
bundle: { windows: { wix: { version: "0.6.60.30201" } } },
|
||||
});
|
||||
const failed = spawnSync(process.execPath, [script, "main", output], { encoding: "utf8" });
|
||||
assert.equal(failed.status, 1);
|
||||
});
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Keep only the newest five versioned release directories of one R2 channel.
|
||||
#
|
||||
# Usage: prune-r2-releases.sh <stable|beta> <current_tag>
|
||||
#
|
||||
# stable: published non-prerelease releases (a promoted beta tag lives under
|
||||
# releases/stable/ too) and also removes legacy releases/<tag>/ dirs.
|
||||
# beta: published releases whose tag is a beta tag, whatever their prerelease
|
||||
# flag, because a promoted beta keeps its releases/beta/<tag>/ directory.
|
||||
#
|
||||
# Nothing is deleted unless five releases are listed and <current_tag> is one
|
||||
# of them, so incomplete release metadata can never remove the wrong versions.
|
||||
# Needs GH_TOKEN, GITHUB_REPOSITORY, R2_ACCOUNT_ID, R2_BUCKET_NAME and AWS credentials.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
family="${1:?family (stable|beta) required}"
|
||||
current_tag="${2:?current tag required}"
|
||||
keep_count=5
|
||||
beta_pattern='^v[0-9]+[.][0-9]+[.][0-9]+-beta[.][0-9]{8}[.][0-9]+$'
|
||||
version_pattern='^v[0-9]+[.][0-9]+[.][0-9]+([.-][0-9A-Za-z.-]+)?$'
|
||||
|
||||
case "$family" in
|
||||
stable)
|
||||
filter='.isDraft == false and .isPrerelease == false and (.tagName | test($version))'
|
||||
;;
|
||||
beta)
|
||||
filter='.isDraft == false and (.tagName | test($beta))'
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown R2 channel: $family" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
mapfile -t keep_tags < <(
|
||||
gh release list --repo "${GITHUB_REPOSITORY}" --limit 100 --json tagName,isDraft,isPrerelease,publishedAt \
|
||||
| jq -r --arg version "$version_pattern" --arg beta "$beta_pattern" --argjson keep "$keep_count" \
|
||||
"[.[] | select($filter)] | sort_by(.publishedAt) | reverse | .[:\$keep] | .[].tagName"
|
||||
)
|
||||
|
||||
if [ "${#keep_tags[@]}" -lt "$keep_count" ]; then
|
||||
echo "::warning::Fewer than ${keep_count} published ${family} releases; skipping ${family} R2 cleanup."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
is_kept() {
|
||||
printf '%s\n' "${keep_tags[@]}" | grep -Fxq -- "$1"
|
||||
}
|
||||
|
||||
if ! is_kept "$current_tag"; then
|
||||
echo "::warning::Current release ${current_tag} is not among the latest ${keep_count} ${family} releases; skipping ${family} R2 cleanup."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Keeping R2 ${family} releases: ${keep_tags[*]}"
|
||||
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
|
||||
list_tags() {
|
||||
local prefix="$1"
|
||||
aws s3api list-objects-v2 --bucket "${R2_BUCKET_NAME}" --prefix "${prefix}" --delimiter "/" \
|
||||
--endpoint-url "${endpoint}" --query 'CommonPrefixes[].Prefix' --output text \
|
||||
| tr '\t' '\n' \
|
||||
| sed -nE "s#^${prefix}(v[0-9]+\\.[0-9]+\\.[0-9]+([.-][0-9A-Za-z.-]+)?)/\$#\\1#p"
|
||||
}
|
||||
|
||||
mapfile -t stored_tags < <(list_tags "releases/${family}/")
|
||||
for tag in "${stored_tags[@]}"; do
|
||||
if is_kept "$tag"; then continue; fi
|
||||
echo "Removing old R2 release: ${family}/${tag}"
|
||||
aws s3 rm "s3://${R2_BUCKET_NAME}/releases/${family}/${tag}/" --recursive --endpoint-url "${endpoint}"
|
||||
done
|
||||
|
||||
if [ "$family" = stable ]; then
|
||||
mapfile -t legacy_tags < <(list_tags "releases/")
|
||||
for tag in "${legacy_tags[@]}"; do
|
||||
echo "Removing legacy R2 release directory: ${tag}"
|
||||
aws s3 rm "s3://${R2_BUCKET_NAME}/releases/${tag}/" --recursive --endpoint-url "${endpoint}"
|
||||
done
|
||||
fi
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const tag = process.argv[2] || "";
|
||||
const root = resolve(process.argv[3] || fileURLToPath(new URL("../..", import.meta.url)));
|
||||
const match = /^v(\d+\.\d+\.\d+)(-beta\.\d{8}\.\d+)?$/.exec(tag);
|
||||
if (!match) {
|
||||
console.error("Usage: set-release-version.mjs <vX.Y.Z[ -beta.YYYYMMDD.N]> [repository_root]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const version = `${match[1]}${match[2] || ""}`;
|
||||
const jsonFiles = ["package.json", "src-tauri/tauri.conf.json"];
|
||||
const cargoFiles = ["src-tauri/Cargo.toml", "crates/dbx-web/Cargo.toml"];
|
||||
|
||||
for (const relativePath of jsonFiles) {
|
||||
const path = resolve(root, relativePath);
|
||||
const data = JSON.parse(readFileSync(path, "utf8"));
|
||||
data.version = version;
|
||||
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
|
||||
}
|
||||
|
||||
for (const relativePath of cargoFiles) {
|
||||
const path = resolve(root, relativePath);
|
||||
const source = readFileSync(path, "utf8");
|
||||
const pattern = /^(version\s*=\s*")[^"]+(")/m;
|
||||
if (!pattern.test(source)) throw new Error(`Package version declaration not found in ${relativePath}.`);
|
||||
const updated = source.replace(pattern, `$1${version}$2`);
|
||||
writeFileSync(path, updated);
|
||||
}
|
||||
|
||||
const lockPath = resolve(root, "Cargo.lock");
|
||||
const lockSource = readFileSync(lockPath, "utf8");
|
||||
let lockUpdated = lockSource;
|
||||
for (const packageName of ["dbx", "dbx-web"]) {
|
||||
const pattern = new RegExp(`(\\[\\[package\\]\\]\\nname = "${packageName}"\\nversion = ")[^\\n"]+(")`);
|
||||
if (!pattern.test(lockUpdated)) throw new Error(`${packageName} package version was not found in Cargo.lock.`);
|
||||
lockUpdated = lockUpdated.replace(pattern, `$1${version}$2`);
|
||||
}
|
||||
writeFileSync(lockPath, lockUpdated);
|
||||
|
||||
for (const relativePath of [...jsonFiles, ...cargoFiles, "Cargo.lock"]) {
|
||||
if (!readFileSync(resolve(root, relativePath), "utf8").includes(version)) {
|
||||
throw new Error(`Release version ${version} was not written to ${relativePath}.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Prepared release version ${version}.`);
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = fileURLToPath(new URL("./set-release-version.mjs", import.meta.url));
|
||||
|
||||
async function run(tag, root) {
|
||||
const child = spawn(process.execPath, [script, tag, root], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
const code = await new Promise((resolve) => child.on("close", resolve));
|
||||
return { code, stderr };
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), "dbx-release-version-"));
|
||||
await mkdir(join(root, "src-tauri"), { recursive: true });
|
||||
await mkdir(join(root, "crates/dbx-web"), { recursive: true });
|
||||
await writeFile(join(root, "package.json"), JSON.stringify({ name: "dbx", version: "0.6.59" }) + "\n");
|
||||
await writeFile(join(root, "src-tauri/tauri.conf.json"), JSON.stringify({ version: "0.6.59" }) + "\n");
|
||||
await writeFile(join(root, "src-tauri/Cargo.toml"), 'version = "0.6.59"\n');
|
||||
await writeFile(join(root, "crates/dbx-web/Cargo.toml"), 'version = "0.6.59"\n');
|
||||
await writeFile(
|
||||
join(root, "Cargo.lock"),
|
||||
'[[package]]\nname = "dbx"\nversion = "0.6.59"\n\n[[package]]\nname = "dbx-web"\nversion = "0.6.59"\n',
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
test("sets the complete beta version across build metadata", async () => {
|
||||
const root = await fixture();
|
||||
const result = await run("v0.6.60-beta.20261029.1", root);
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
for (const relativePath of ["package.json", "src-tauri/tauri.conf.json", "src-tauri/Cargo.toml", "crates/dbx-web/Cargo.toml", "Cargo.lock"]) {
|
||||
assert.match(await readFile(join(root, relativePath), "utf8"), /0\.6\.60-beta\.20261029\.1/);
|
||||
}
|
||||
});
|
||||
|
||||
test("sets the base version for a stable build", async () => {
|
||||
const root = await fixture();
|
||||
const result = await run("v0.6.60", root);
|
||||
assert.equal(result.code, 0, result.stderr);
|
||||
assert.match(await readFile(join(root, "src-tauri/Cargo.toml"), "utf8"), /version = "0\.6\.60"/);
|
||||
});
|
||||
|
||||
test("accepts an already prepared stable or beta checkout without changing its metadata", async () => {
|
||||
for (const tag of ["v0.6.59", "v0.6.60-beta.20261029.1"]) {
|
||||
const root = await fixture();
|
||||
const first = await run(tag, root);
|
||||
assert.equal(first.code, 0, first.stderr);
|
||||
const files = ["package.json", "src-tauri/tauri.conf.json", "src-tauri/Cargo.toml", "crates/dbx-web/Cargo.toml", "Cargo.lock"];
|
||||
const before = await Promise.all(files.map((path) => readFile(join(root, path), "utf8")));
|
||||
const second = await run(tag, root);
|
||||
assert.equal(second.code, 0, second.stderr);
|
||||
assert.deepEqual(await Promise.all(files.map((path) => readFile(join(root, path), "utf8"))), before);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a lockfile missing one of the application packages", async () => {
|
||||
const root = await fixture();
|
||||
await writeFile(join(root, "Cargo.lock"), '[[package]]\nname = "dbx"\nversion = "0.6.59"\n');
|
||||
const result = await run("v0.6.60", root);
|
||||
assert.notEqual(result.code, 0);
|
||||
assert.match(result.stderr, /dbx-web package version was not found/);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export function validateReleaseCi(pages, sha) {
|
||||
const runs = pages.flatMap((page) => page.workflow_runs ?? [])
|
||||
.filter((run) => run.head_sha === sha && ["push", "pull_request"].includes(run.event))
|
||||
.sort((a, b) => b.run_number - a.run_number || b.run_attempt - a.run_attempt);
|
||||
const latest = runs[0];
|
||||
if (!latest || latest.status !== "completed" || latest.conclusion !== "success") {
|
||||
throw new Error(`Latest CI run for ${sha} must complete successfully before promotion (${latest ? `${latest.status}/${latest.conclusion}` : "no CI run"}).`);
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const [repository, sha] = process.argv.slice(2);
|
||||
if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? "") || !/^[a-f0-9]{40}$/i.test(sha ?? "")) {
|
||||
throw new Error("Usage: validate-release-ci.mjs <owner/repo> <commit-sha>");
|
||||
}
|
||||
// Query the CI workflow alone: the promotion workflow runs on the same
|
||||
// commit and must never fail its own gate merely because it is in progress.
|
||||
const pages = JSON.parse(execFileSync("gh", [
|
||||
"api", "--paginate", "--slurp",
|
||||
`repos/${repository}/actions/workflows/ci.yml/runs?head_sha=${sha}&per_page=100`,
|
||||
], { encoding: "utf8" }));
|
||||
const run = validateReleaseCi(pages, sha);
|
||||
console.log(`Validated CI run ${run.id} for ${sha}.`);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { validateReleaseCi } from "./validate-release-ci.mjs";
|
||||
|
||||
const sha = "a".repeat(40);
|
||||
const success = { id: 1, head_sha: sha, event: "push", run_number: 1, run_attempt: 1, status: "completed", conclusion: "success" };
|
||||
|
||||
test("accepts the successful CI run for the release commit", () => {
|
||||
assert.equal(validateReleaseCi([{ workflow_runs: [success] }], sha), success);
|
||||
});
|
||||
|
||||
test("rejects missing CI and success belonging to a different commit", () => {
|
||||
assert.throws(() => validateReleaseCi([{ workflow_runs: [] }], sha), /no CI run/);
|
||||
assert.throws(() => validateReleaseCi([{ workflow_runs: [success] }], "b".repeat(40)), /no CI run/);
|
||||
});
|
||||
|
||||
test("rejects failed, cancelled, skipped, and running CI", () => {
|
||||
for (const conclusion of ["failure", "cancelled", "skipped", "neutral", null]) {
|
||||
const run = { ...success, conclusion, status: conclusion ? "completed" : "in_progress" };
|
||||
assert.throws(() => validateReleaseCi([{ workflow_runs: [run] }], sha), /must complete successfully/);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not accept an earlier success when the latest run fails", () => {
|
||||
const failed = { ...success, run_number: 2, conclusion: "failure" };
|
||||
assert.throws(() => validateReleaseCi([{ workflow_runs: [success, failed] }], sha), /completed\/failure/);
|
||||
});
|
||||
|
||||
test("reads all result pages and respects rerun attempts", () => {
|
||||
const failed = { ...success, conclusion: "failure" };
|
||||
const rerun = { ...success, run_attempt: 2 };
|
||||
assert.equal(validateReleaseCi([{ workflow_runs: [failed] }, { workflow_runs: [rerun] }], sha), rerun);
|
||||
});
|
||||
|
||||
test("unrelated dispatches cannot satisfy or block the source CI gate", () => {
|
||||
const dispatch = { ...success, event: "workflow_dispatch", run_number: 10, status: "in_progress", conclusion: null };
|
||||
assert.equal(validateReleaseCi([{ workflow_runs: [dispatch, success] }], sha), success);
|
||||
assert.throws(() => validateReleaseCi([{ workflow_runs: [{ ...dispatch, status: "completed", conclusion: "success" }] }], sha), /no CI run/);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Daily Beta Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "15 2 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base_version:
|
||||
description: "Stable SemVer base to build (defaults to dbx-web Cargo.toml)"
|
||||
required: false
|
||||
type: string
|
||||
force:
|
||||
description: "Build even when the latest beta or stable release already contains this commit"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: daily-beta-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
create-beta:
|
||||
name: Create daily beta tag
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout release source
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Create beta draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_WORKFLOW_TOKEN: ${{ secrets.MCP_RELEASE_TOKEN }}
|
||||
REQUESTED_BASE_VERSION: ${{ inputs.base_version }}
|
||||
FORCE_BUILD: ${{ inputs.force }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
stable_tag="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq '.tagName // empty' 2>/dev/null || true)"
|
||||
stable_version="${stable_tag#v}"
|
||||
stable_version="${stable_version%%-beta.*}"
|
||||
|
||||
base_version="$REQUESTED_BASE_VERSION"
|
||||
if [[ -z "$base_version" ]]; then
|
||||
base_version="$(cargo metadata --locked --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "dbx-web") | .version')"
|
||||
# A promoted beta becomes the stable release, so the next beta must
|
||||
# build the following patch. Source versions ahead of that (a
|
||||
# minor or major bump) still win.
|
||||
if [[ "$stable_version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
next_patch="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
|
||||
base_version="$(printf '%s\n%s\n' "$base_version" "$next_patch" | sort -V | tail -n 1)"
|
||||
fi
|
||||
fi
|
||||
if [[ ! "$base_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Beta base version is not a stable SemVer: $base_version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$stable_version" == "$base_version" ]]; then
|
||||
echo "Source version $base_version matches stable; publishing a daily beta build from the current source."
|
||||
fi
|
||||
|
||||
# Skip days without new commits: nothing changed since the last
|
||||
# published beta (or the stable release) was built from this source.
|
||||
# Automation commits marked [skip ci] (version sync, plugin version
|
||||
# bumps) do not count as changes.
|
||||
if [[ "$FORCE_BUILD" != "true" && -z "$REQUESTED_BASE_VERSION" ]]; then
|
||||
latest_beta_tag="$(gh release list --repo "$GITHUB_REPOSITORY" --limit 100 --json tagName,isDraft \
|
||||
--jq '.[] | select(.isDraft == false) | .tagName' \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]{8}\.[0-9]+$' | sort -V | tail -n 1 || true)"
|
||||
for built_tag in "$latest_beta_tag" "$stable_tag"; do
|
||||
[[ -n "$built_tag" ]] || continue
|
||||
comparison="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${built_tag}...${GITHUB_SHA}" \
|
||||
--jq '[.status, ([.commits[].commit.message | select(contains("[skip ci]") | not)] | length)] | @tsv' 2>/dev/null || true)"
|
||||
read -r compare_status new_commits <<<"$comparison"
|
||||
if [[ "$compare_status" =~ ^(identical|ahead|behind)$ && "${new_commits:-1}" == "0" ]]; then
|
||||
echo "::notice::No new commits since ${built_tag}; skipping the daily beta build."
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
date_suffix="$(date -u +%Y%m%d)"
|
||||
sequence=1
|
||||
while :; do
|
||||
tag="v${base_version}-beta.${date_suffix}.${sequence}"
|
||||
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \
|
||||
&& ! gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sequence=$((sequence + 1))
|
||||
done
|
||||
|
||||
if [[ -z "$RELEASE_WORKFLOW_TOKEN" ]]; then
|
||||
echo "::error::MCP_RELEASE_TOKEN is required to dispatch the release workflow because GITHUB_TOKEN-created events do not trigger another workflow."
|
||||
exit 1
|
||||
fi
|
||||
# A draft release does not publish its tag. Dispatch requires an
|
||||
# existing ref so every matrix job can check out this exact source.
|
||||
gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \
|
||||
-f "ref=refs/tags/${tag}" -f "sha=${GITHUB_SHA}"
|
||||
gh release create "$tag" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--verify-tag \
|
||||
--draft \
|
||||
--title "DBX ${base_version} Beta ${date_suffix}.${sequence}" \
|
||||
--generate-notes
|
||||
GH_TOKEN="$RELEASE_WORKFLOW_TOKEN" gh workflow run release.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref "$tag" \
|
||||
-f release_tag="$tag"
|
||||
echo "Created beta draft release ${tag} and dispatched the Release workflow explicitly."
|
||||
@@ -13,6 +13,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
|
||||
env:
|
||||
@@ -21,8 +22,96 @@ env:
|
||||
REPO_OWNER: t8y2
|
||||
|
||||
jobs:
|
||||
validate-candidate:
|
||||
name: Validate promotion candidate
|
||||
needs: approve-promotion
|
||||
# approve-promotion is skipped for plain version tags, which are published
|
||||
# automatically after the Release workflow; rejected approvals still block.
|
||||
if: ${{ !cancelled() && contains(fromJSON('["success", "skipped"]'), needs.approve-promotion.result) }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
published_at: ${{ steps.release.outputs.published_at }}
|
||||
is_beta: ${{ steps.version.outputs.is_beta }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract and validate beta tag
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ inputs.tag }}"
|
||||
TAG="$(echo "${TAG}" | xargs)"
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]{8}\.[0-9]+$ ]]; then
|
||||
echo "is_beta=true" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "is_beta=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::Tag must use vX.Y.Z or vX.Y.Z-beta.YYYYMMDD.N format: $TAG"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
VERSION="${TAG#v}"
|
||||
echo "version=${VERSION%%-beta.*}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate published prerelease and CI checks
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
RELEASE_JSON="$(gh release view "$TAG" --repo "${{ github.repository }}" --json isDraft,isPrerelease,assets)"
|
||||
if [[ "$(jq -r '.isDraft' <<<"$RELEASE_JSON")" == "true" ]]; then
|
||||
echo "::error::Release $TAG must be published before promotion."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${{ steps.version.outputs.is_beta }}" == "true" && "$(jq -r '.isPrerelease' <<<"$RELEASE_JSON")" == "false" ]]; then
|
||||
echo "::notice::Beta promotion candidate $TAG is already stable; continuing idempotently."
|
||||
fi
|
||||
|
||||
PUBLISHED_AT="$(gh release view "$TAG" --repo "${{ github.repository }}" --json publishedAt --jq '.publishedAt // empty')"
|
||||
test -n "$PUBLISHED_AT"
|
||||
echo "published_at=${PUBLISHED_AT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
REF_JSON="$(gh api "repos/${{ github.repository }}/git/ref/tags/${TAG}")"
|
||||
SHA="$(jq -r '.object.sha' <<<"$REF_JSON")"
|
||||
if [[ "$(jq -r '.object.type' <<<"$REF_JSON")" == tag ]]; then
|
||||
SHA="$(gh api "repos/${{ github.repository }}/git/tags/${SHA}" --jq '.object.sha')"
|
||||
fi
|
||||
node .github/scripts/validate-release-ci.mjs "$GITHUB_REPOSITORY" "$SHA"
|
||||
|
||||
- name: Validate updater manifest and platform assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
mkdir -p "$RUNNER_TEMP/promotion-assets"
|
||||
gh release download "$TAG" --repo "${{ github.repository }}" --pattern latest.json --dir "$RUNNER_TEMP/promotion-assets" --clobber
|
||||
gh release view "$TAG" --repo "${{ github.repository }}" --json assets --jq '.assets[].name' > "$RUNNER_TEMP/promotion-assets/release-assets.txt"
|
||||
MANIFEST="$RUNNER_TEMP/promotion-assets/latest.json"
|
||||
test -s "$MANIFEST"
|
||||
test -n "$(jq -r '.version // empty' "$MANIFEST")"
|
||||
for platform in darwin-aarch64 darwin-x86_64 windows-x86_64 windows-aarch64 linux-x86_64 linux-aarch64; do
|
||||
if ! jq -e --arg platform "$platform" '.platforms[$platform] | type == "object" and (.url | type == "string" and length > 0) and (.signature | type == "string" and length > 0)' "$MANIFEST" >/dev/null; then
|
||||
echo "::error::Manifest platform $platform is missing a URL or signature."
|
||||
exit 1
|
||||
fi
|
||||
URL="$(jq -r --arg platform "$platform" '.platforms[$platform].url' "$MANIFEST")"
|
||||
ASSET="$(basename "${URL%%\?*}")"
|
||||
if ! grep -Fxq "$ASSET" "$RUNNER_TEMP/promotion-assets/release-assets.txt"; then
|
||||
echo "::error::Manifest platform $platform references missing release asset $ASSET."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
publish:
|
||||
name: Publish to Homebrew & Scoop
|
||||
needs: [publish-stable-alias, validate-candidate]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -34,6 +123,9 @@ jobs:
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
TAG="$(echo "${TAG}" | xargs)"
|
||||
VERSION="${TAG#v}"
|
||||
# Beta tags identify the GitHub Release, while package assets keep
|
||||
# the stable base version in their filenames.
|
||||
VERSION="${VERSION%%-beta.*}"
|
||||
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
||||
echo "::error::Tag '${TAG}' does not look like a semver version."
|
||||
exit 1
|
||||
@@ -97,6 +189,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
SHA_ARM64="${{ steps.hashes.outputs.sha_dmg_arm64 }}"
|
||||
SHA_X64="${{ steps.hashes.outputs.sha_dmg_x64 }}"
|
||||
|
||||
@@ -119,7 +212,7 @@ jobs:
|
||||
sha256 arm: "__SHA_ARM64__",
|
||||
intel: "__SHA_X64__"
|
||||
|
||||
url "https://github.com/__OWNER__/__APP__/releases/download/v#{version}/__PRODUCT___#{version}_#{arch}.dmg"
|
||||
url "https://github.com/__OWNER__/__APP__/releases/download/__TAG__/__PRODUCT___#{version}_#{arch}.dmg"
|
||||
name "DBX"
|
||||
desc "Database management tool"
|
||||
homepage "https://dbxio.com/"
|
||||
@@ -140,6 +233,7 @@ jobs:
|
||||
sed -i 's/^ //' Casks/dbx.rb
|
||||
|
||||
sed -i "s/__VERSION__/${VERSION}/g" Casks/dbx.rb
|
||||
sed -i "s/__TAG__/${TAG}/g" Casks/dbx.rb
|
||||
sed -i "s/__OWNER__/${REPO_OWNER}/g" Casks/dbx.rb
|
||||
sed -i "s/__APP__/${APP_NAME}/g" Casks/dbx.rb
|
||||
sed -i "s/__PRODUCT__/${PRODUCT_NAME}/g" Casks/dbx.rb
|
||||
@@ -167,6 +261,7 @@ jobs:
|
||||
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
SHA_EXE_X64="${{ steps.hashes.outputs.sha_exe_x64 }}"
|
||||
SHA_EXE_ARM64="${{ steps.hashes.outputs.sha_exe_arm64 }}"
|
||||
|
||||
@@ -183,11 +278,11 @@ jobs:
|
||||
"license": "Apache-2.0",
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/${REPO_OWNER}/${APP_NAME}/releases/download/v${VERSION}/${PRODUCT_NAME}_${VERSION}_x64-setup.exe",
|
||||
"url": "https://github.com/${REPO_OWNER}/${APP_NAME}/releases/download/${TAG}/${PRODUCT_NAME}_${VERSION}_x64-setup.exe",
|
||||
"hash": "${SHA_EXE_X64}"
|
||||
},
|
||||
"arm64": {
|
||||
"url": "https://github.com/${REPO_OWNER}/${APP_NAME}/releases/download/v${VERSION}/${PRODUCT_NAME}_${VERSION}_arm64-setup.exe",
|
||||
"url": "https://github.com/${REPO_OWNER}/${APP_NAME}/releases/download/${TAG}/${PRODUCT_NAME}_${VERSION}_arm64-setup.exe",
|
||||
"hash": "${SHA_EXE_ARM64}"
|
||||
}
|
||||
},
|
||||
@@ -205,10 +300,10 @@ jobs:
|
||||
"autoupdate": {
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/${REPO_OWNER}/${APP_NAME}/releases/download/v\$version/${PRODUCT_NAME}_\$version_x64-setup.exe"
|
||||
"url": "https://github.com/${REPO_OWNER}/${APP_NAME}/releases/latest/download/${PRODUCT_NAME}_\$version_x64-setup.exe"
|
||||
},
|
||||
"arm64": {
|
||||
"url": "https://github.com/${REPO_OWNER}/${APP_NAME}/releases/download/v\$version/${PRODUCT_NAME}_\$version_arm64-setup.exe"
|
||||
"url": "https://github.com/${REPO_OWNER}/${APP_NAME}/releases/latest/download/${PRODUCT_NAME}_\$version_arm64-setup.exe"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,13 +326,17 @@ jobs:
|
||||
|
||||
upload-r2:
|
||||
name: Upload to R2
|
||||
needs: validate-candidate
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
TAG="$(echo "${TAG}" | xargs)"
|
||||
TAG="${{ needs.validate-candidate.outputs.tag }}"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download release assets
|
||||
@@ -271,6 +370,9 @@ jobs:
|
||||
echo "Platform manifest complete:"
|
||||
jq -r '.platforms | keys[]' "$LATEST_JSON"
|
||||
|
||||
- name: Mark promoted stable manifest
|
||||
run: node .github/scripts/prepare-channel-manifest.mjs assets/latest.json assets/latest.json stable "${{ steps.version.outputs.tag }}" true "${{ needs.validate-candidate.outputs.published_at }}"
|
||||
|
||||
- name: Rewrite latest.json download URLs to R2 CDN
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
@@ -283,7 +385,11 @@ jobs:
|
||||
jq '.platforms[]?.url // empty' "$LATEST_JSON"
|
||||
jq --arg tag "$TAG" '
|
||||
(.platforms[]?.url?) |= (
|
||||
sub("https://github\\.com/t8y2/dbx/releases/download/" + $tag + "/"; "https://dl.dbxio.com/releases/latest/")
|
||||
sub("\\?.*$"; "")
|
||||
| if startswith("https://github.com/t8y2/dbx/releases/download/" + $tag + "/")
|
||||
then sub("https://github\\.com/t8y2/dbx/releases/download/" + $tag + "/"; "https://dl.dbxio.com/releases/stable/" + $tag + "/")
|
||||
else .
|
||||
end
|
||||
+ "?v=" + $tag
|
||||
)
|
||||
' "$LATEST_JSON" > "$LATEST_JSON.tmp"
|
||||
@@ -292,114 +398,28 @@ jobs:
|
||||
jq '.platforms[]?.url // empty' "$LATEST_JSON"
|
||||
|
||||
- name: Upload to R2 (versioned)
|
||||
run: |
|
||||
aws s3 sync ./assets "s3://${{ secrets.R2_BUCKET_NAME }}/releases/${{ steps.version.outputs.tag }}/" \
|
||||
--endpoint-url "https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
|
||||
- name: Upload to R2 (latest)
|
||||
run: |
|
||||
aws s3 sync ./assets "s3://${{ secrets.R2_BUCKET_NAME }}/releases/latest/" \
|
||||
--endpoint-url "https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com" \
|
||||
--cache-control "no-cache" \
|
||||
--delete
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
|
||||
- name: Keep latest five versioned releases in R2
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
CURRENT_TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mapfile -t keep_tags < <(
|
||||
gh release list \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--limit 100 \
|
||||
--json tagName,isDraft,publishedAt \
|
||||
| jq -r '[.[] | select(.isDraft == false and (.tagName | test("^v[0-9]+[.][0-9]+[.][0-9]+([.-][0-9A-Za-z.-]+)?$")))] | sort_by(.publishedAt) | reverse | .[:5] | .[].tagName'
|
||||
)
|
||||
|
||||
# Skip cleanup if release metadata is incomplete so publishing can never delete the wrong versions.
|
||||
if [ "${#keep_tags[@]}" -lt 5 ]; then
|
||||
echo "::warning::Found only ${#keep_tags[@]} published releases; skipping R2 cleanup."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
is_kept_release() {
|
||||
local candidate="$1"
|
||||
printf '%s\n' "${keep_tags[@]}" | grep -Fxq -- "${candidate}"
|
||||
}
|
||||
|
||||
if ! is_kept_release "${CURRENT_TAG}"; then
|
||||
echo "::warning::Current release ${CURRENT_TAG} is not among the latest five releases; skipping R2 cleanup."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Keeping R2 releases: ${keep_tags[*]}"
|
||||
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
mapfile -t stored_tags < <(
|
||||
aws s3api list-objects-v2 \
|
||||
--bucket "${R2_BUCKET_NAME}" \
|
||||
--prefix "releases/" \
|
||||
--delimiter "/" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--query 'CommonPrefixes[].Prefix' \
|
||||
--output text \
|
||||
| tr '\t' '\n' \
|
||||
| sed -nE 's#^releases/(v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?)/$#\1#p'
|
||||
)
|
||||
|
||||
for tag in "${stored_tags[@]}"; do
|
||||
if is_kept_release "${tag}"; then
|
||||
continue
|
||||
endpoint="https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
|
||||
manifest_key="releases/stable/${{ steps.version.outputs.tag }}/latest.json"
|
||||
if aws s3api head-object --bucket "${{ secrets.R2_BUCKET_NAME }}" --key "$manifest_key" --endpoint-url "$endpoint" >/dev/null 2>&1; then
|
||||
aws s3 cp "s3://${{ secrets.R2_BUCKET_NAME }}/${manifest_key}" "$RUNNER_TEMP/existing-stable-manifest.json" --endpoint-url "$endpoint"
|
||||
if ! cmp -s assets/latest.json "$RUNNER_TEMP/existing-stable-manifest.json"; then
|
||||
echo "::error::Immutable stable manifest already exists for ${{ steps.version.outputs.tag }} with different contents."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Removing old R2 release: ${tag}"
|
||||
aws s3 rm "s3://${R2_BUCKET_NAME}/releases/${tag}/" \
|
||||
--recursive \
|
||||
--endpoint-url "${endpoint}"
|
||||
done
|
||||
|
||||
- name: Purge Cloudflare cache
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zone_id="${CLOUDFLARE_ZONE_ID:-}"
|
||||
if [ -z "$zone_id" ]; then
|
||||
zone_id="$(curl -fsSL \
|
||||
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://api.cloudflare.com/client/v4/zones?name=dbxio.com" \
|
||||
| jq -r '.result[0].id // empty')"
|
||||
echo "Stable manifest already exists with identical contents; continuing idempotently."
|
||||
fi
|
||||
|
||||
if [ -z "$zone_id" ]; then
|
||||
echo "::error::Cloudflare zone dbxio.com not found. Set CLOUDFLARE_ZONE_ID or grant Zone:Read to CLOUDFLARE_API_TOKEN."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -fsSL -X POST \
|
||||
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"purge_everything":true}' \
|
||||
"https://api.cloudflare.com/client/v4/zones/${zone_id}/purge_cache" \
|
||||
| jq -e '.success == true'
|
||||
aws s3 sync ./assets "s3://${{ secrets.R2_BUCKET_NAME }}/releases/stable/${{ steps.version.outputs.tag }}/" \
|
||||
--endpoint-url "$endpoint"
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
|
||||
|
||||
promote-release:
|
||||
name: Promote to Release
|
||||
needs: [publish, upload-r2]
|
||||
needs: [upload-r2, validate-candidate]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -409,35 +429,242 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
gh release edit "${TAG}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--prerelease=false \
|
||||
--latest
|
||||
TAG="${{ needs.validate-candidate.outputs.tag }}"
|
||||
edit_args=(--repo "${{ github.repository }}" --prerelease=false --latest)
|
||||
if [[ "${{ needs.validate-candidate.outputs.is_beta }}" == "true" ]]; then
|
||||
# A promoted beta keeps its beta tag, but the page is a stable release.
|
||||
edit_args+=(--title "DBX v${{ needs.validate-candidate.outputs.version }}")
|
||||
fi
|
||||
gh release edit "${TAG}" "${edit_args[@]}"
|
||||
echo "::notice::Release ${TAG} promoted from prerelease to latest release"
|
||||
|
||||
- name: Promote CNB prerelease to release
|
||||
- name: Publish stable manifest to GitHub Release
|
||||
if: ${{ needs.validate-candidate.outputs.is_beta == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.validate-candidate.outputs.tag }}
|
||||
PUBLISHED_AT: ${{ needs.validate-candidate.outputs.published_at }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest_dir="$RUNNER_TEMP/stable-manifest"
|
||||
mkdir -p "$manifest_dir"
|
||||
gh release download "$TAG" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern latest.json \
|
||||
--dir "$manifest_dir" \
|
||||
--clobber
|
||||
node .github/scripts/prepare-channel-manifest.mjs \
|
||||
"$manifest_dir/latest.json" \
|
||||
"$manifest_dir/latest.json" \
|
||||
stable "$TAG" true "$PUBLISHED_AT"
|
||||
jq -e --arg tag "$TAG" '
|
||||
.channel == "stable" and
|
||||
.release_tag == $tag and
|
||||
.is_promoted == true and
|
||||
(.version | test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))
|
||||
' "$manifest_dir/latest.json" >/dev/null
|
||||
gh release upload "$TAG" "$manifest_dir/latest.json" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--clobber
|
||||
|
||||
- name: Add stable package asset aliases
|
||||
if: ${{ needs.validate-candidate.outputs.is_beta == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.validate-candidate.outputs.tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag_version="${TAG#v}"
|
||||
base_version="${tag_version%%-beta.*}"
|
||||
mkdir -p "$RUNNER_TEMP/stable-package-assets"
|
||||
assets_json="$(gh release view "$TAG" --repo "${GITHUB_REPOSITORY}" --json assets)"
|
||||
for suffix in arm64.dmg x64.dmg x64-setup.exe arm64-setup.exe x64_en-US.msi arm64_en-US.msi; do
|
||||
source="DBX_${tag_version}_${suffix}"
|
||||
target="DBX_${base_version}_${suffix}"
|
||||
if ! jq -e --arg name "$target" '.assets[] | select(.name == $name)' <<<"$assets_json" >/dev/null; then
|
||||
if ! jq -e --arg name "$source" '.assets[] | select(.name == $name)' <<<"$assets_json" >/dev/null; then
|
||||
echo "::error::Missing beta package asset $source."
|
||||
exit 1
|
||||
fi
|
||||
gh release download "$TAG" --repo "${GITHUB_REPOSITORY}" --pattern "$source" --dir "$RUNNER_TEMP/stable-package-assets" --clobber
|
||||
cp "$RUNNER_TEMP/stable-package-assets/$source" "$RUNNER_TEMP/stable-package-assets/$target"
|
||||
gh release upload "$TAG" "$RUNNER_TEMP/stable-package-assets/$target" --repo "${GITHUB_REPOSITORY}" --clobber
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Sync promoted release to CNB
|
||||
env:
|
||||
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.event.inputs.tag }}
|
||||
TAG: ${{ needs.validate-candidate.outputs.tag }}
|
||||
if: ${{ needs.validate-candidate.outputs.is_beta == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/github-release"
|
||||
mkdir -p "$RUNNER_TEMP/github-release" "$RUNNER_TEMP/release-assets"
|
||||
gh release view "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--json tagName,name,body,targetCommitish,isPrerelease,isDraft \
|
||||
--json tagName,name,body,targetCommitish,isPrerelease,isDraft,assets \
|
||||
> "$RUNNER_TEMP/github-release/release.json"
|
||||
|
||||
# Sync metadata only so promoting a release never re-uploads large CNB assets.
|
||||
gh release download "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir "$RUNNER_TEMP/release-assets" \
|
||||
--clobber
|
||||
node .github/scripts/sync-cnb-release.mjs \
|
||||
--github-release "$RUNNER_TEMP/github-release/release.json" \
|
||||
--metadata-only \
|
||||
--assets-dir "$RUNNER_TEMP/release-assets" \
|
||||
--make-latest
|
||||
|
||||
promote-docker:
|
||||
name: Promote Docker images
|
||||
# Beta builds push version-tagged images only. Promotion re-tags the same
|
||||
# immutable manifests as the stable version and latest without rebuilding.
|
||||
# Plain version tags already receive latest from the Release workflow.
|
||||
if: ${{ needs.validate-candidate.outputs.is_beta == 'true' }}
|
||||
needs: [validate-candidate, promote-release]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CNB_REPOSITORY: "dbxio.com/dbx"
|
||||
CNB_REGISTRY: "docker.cnb.cool"
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to CNB Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.CNB_REGISTRY }}
|
||||
username: cnb
|
||||
password: ${{ secrets.CNB_TOKEN }}
|
||||
|
||||
- name: Tag promoted images
|
||||
env:
|
||||
TAG: ${{ needs.validate-candidate.outputs.tag }}
|
||||
STABLE_VERSION: ${{ needs.validate-candidate.outputs.version }}
|
||||
DOCKERHUB_REPOSITORY: ${{ secrets.DOCKERHUB_USERNAME }}/dbx
|
||||
CNB_IMAGE: ${{ env.CNB_REGISTRY }}/${{ env.CNB_REPOSITORY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_version="${TAG#v}"
|
||||
for image in "${DOCKERHUB_REPOSITORY}" "${CNB_IMAGE}"; do
|
||||
docker buildx imagetools inspect "${image}:${source_version}" >/dev/null
|
||||
docker buildx imagetools create -t "${image}:${STABLE_VERSION}" -t "${image}:latest" "${image}:${source_version}"
|
||||
done
|
||||
|
||||
sync-source-version:
|
||||
name: Sync source version to main
|
||||
# Release builds rewrite version files only inside their runners, so the
|
||||
# promoted version is committed here. The daily beta job and local builds
|
||||
# read the source version, which would otherwise stay on an old release.
|
||||
needs: [validate-candidate, promote-release]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
# A push to main needs the release token that can bypass the branch ruleset.
|
||||
token: ${{ secrets.MCP_RELEASE_TOKEN }}
|
||||
|
||||
- name: Commit promoted version
|
||||
env:
|
||||
VERSION: ${{ needs.validate-candidate.outputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current="$(jq -r '.version' package.json)"
|
||||
if [ "$current" = "$VERSION" ] || [ "$(printf '%s\n%s\n' "$current" "$VERSION" | sort -V | tail -n 1)" != "$VERSION" ]; then
|
||||
echo "Source version ${current} is already at or ahead of ${VERSION}; nothing to sync."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
node .github/scripts/set-release-version.mjs "v${VERSION}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml crates/dbx-web/Cargo.toml Cargo.lock
|
||||
git commit -m "chore(release): sync source version to ${VERSION} [skip ci]"
|
||||
for attempt in 1 2 3; do
|
||||
if git push origin HEAD:main; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "::error::Unable to push the source version bump after ${attempt} attempts."
|
||||
exit 1
|
||||
fi
|
||||
git fetch origin main --no-tags
|
||||
git rebase origin/main
|
||||
done
|
||||
|
||||
publish-stable-alias:
|
||||
name: Publish stable update aliases
|
||||
needs: [promote-release, upload-r2]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Copy immutable manifest to stable aliases
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
TAG: ${{ needs.upload-r2.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
source="s3://${R2_BUCKET_NAME}/releases/stable/${TAG}/"
|
||||
for alias in stable/latest latest; do
|
||||
aws s3 sync "${source}" "s3://${R2_BUCKET_NAME}/releases/${alias}/" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--cache-control "no-cache" \
|
||||
--delete
|
||||
done
|
||||
aws s3 cp "${source}latest.json" "s3://${R2_BUCKET_NAME}/releases/stable/latest.json" \
|
||||
--endpoint-url "${endpoint}" --cache-control "no-cache"
|
||||
aws s3 cp "${source}latest.json" "s3://${R2_BUCKET_NAME}/releases/latest/latest.json" \
|
||||
--endpoint-url "${endpoint}" --cache-control "no-cache"
|
||||
|
||||
- name: Keep latest five versioned releases in R2
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: bash .github/scripts/prune-r2-releases.sh stable "${{ needs.upload-r2.outputs.tag }}"
|
||||
|
||||
- name: Purge Cloudflare cache
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zone_id="${CLOUDFLARE_ZONE_ID:-}"
|
||||
if [ -z "$zone_id" ]; then
|
||||
zone_id="$(curl -fsSL -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" -H "Content-Type: application/json" \
|
||||
"https://api.cloudflare.com/client/v4/zones?name=dbxio.com" | jq -r '.result[0].id // empty')"
|
||||
fi
|
||||
test -n "$zone_id"
|
||||
curl -fsSL -X POST -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" -H "Content-Type: application/json" \
|
||||
--data '{"purge_everything":true}' "https://api.cloudflare.com/client/v4/zones/${zone_id}/purge_cache" | jq -e '.success == true'
|
||||
|
||||
approve-promotion:
|
||||
name: Approve stable promotion
|
||||
if: ${{ contains(inputs.tag, '-beta.') }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: release-promotion
|
||||
steps:
|
||||
- run: echo "Stable promotion approved for ${{ github.event.inputs.tag }}"
|
||||
|
||||
notify-qq:
|
||||
name: QQ Notify
|
||||
needs: [promote-release]
|
||||
needs: [publish]
|
||||
if: ${{ inputs.notify }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -505,7 +732,7 @@ jobs:
|
||||
|
||||
notify-feishu:
|
||||
name: Notify Feishu group
|
||||
needs: [promote-release]
|
||||
needs: [publish]
|
||||
if: ${{ inputs.notify }}
|
||||
# Card sends after promotion so the title reflects the formal release.
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+207
-71
@@ -4,11 +4,18 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Tag containing the draft release to build"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
|
||||
CNB_REPOSITORY: "dbxio.com/dbx"
|
||||
CNB_USERNAME: "cnb"
|
||||
CNB_REGISTRY: "docker.cnb.cool"
|
||||
@@ -24,11 +31,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
for attempt in $(seq 1 30); do
|
||||
RELEASE_JSON="$(gh release view "${GITHUB_REF_NAME}" \
|
||||
RELEASE_JSON="$(gh release view "${RELEASE_TAG}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--json tagName,isDraft,body 2>/dev/null || true)"
|
||||
if [ -n "$RELEASE_JSON" ] \
|
||||
&& [ "$(jq -r '.tagName' <<<"$RELEASE_JSON")" = "${GITHUB_REF_NAME}" ] \
|
||||
&& [ "$(jq -r '.tagName' <<<"$RELEASE_JSON")" = "${RELEASE_TAG}" ] \
|
||||
&& [ "$(jq -r '.isDraft' <<<"$RELEASE_JSON")" = true ] \
|
||||
&& [ -n "$(jq -r '.body' <<<"$RELEASE_JSON")" ]; then
|
||||
echo "Agent-created draft release is ready."
|
||||
@@ -36,7 +43,7 @@ jobs:
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Agent-created draft release was not ready for ${GITHUB_REF_NAME}." >&2
|
||||
echo "Agent-created draft release was not ready for ${RELEASE_TAG}." >&2
|
||||
exit 1
|
||||
|
||||
nix-packaging:
|
||||
@@ -44,6 +51,8 @@ jobs:
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.release_tag || github.ref }}
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
|
||||
@@ -61,6 +70,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.release_tag || github.ref }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.MCP_RELEASE_TOKEN }}
|
||||
|
||||
@@ -151,6 +161,11 @@ jobs:
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.release_tag || github.ref }}
|
||||
|
||||
- name: Set release version
|
||||
run: node .github/scripts/set-release-version.mjs "${RELEASE_TAG}"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -306,7 +321,7 @@ jobs:
|
||||
APPLE_PASSWORD: ${{ startsWith(matrix.platform, 'macos') && secrets.APPLE_PASSWORD || '' }}
|
||||
APPLE_TEAM_ID: ${{ startsWith(matrix.platform, 'macos') && secrets.APPLE_TEAM_ID || '' }}
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
tagName: ${{ inputs.release_tag || github.ref_name }}
|
||||
releaseDraft: true
|
||||
# Empty on x64 legs: tauri-action then keeps the default Tauri CLI names.
|
||||
assetNamePattern: ${{ matrix.asset_pattern }}
|
||||
@@ -347,6 +362,12 @@ jobs:
|
||||
VSIGN_TOOL_SHA256: ${{ vars.VSIGN_TOOL_SHA256 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.release_tag || github.ref }}
|
||||
|
||||
- name: Set release version
|
||||
shell: pwsh
|
||||
run: node .github/scripts/set-release-version.mjs "${env:RELEASE_TAG}"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -447,6 +468,10 @@ jobs:
|
||||
VSIGN_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
|
||||
run: ./.github/scripts/setup-vsign.ps1
|
||||
|
||||
- name: Prepare MSI version
|
||||
shell: pwsh
|
||||
run: node .github/scripts/prepare-msi-config.mjs "${env:RELEASE_TAG}" src-tauri/tauri.msi-version.conf.json
|
||||
|
||||
- name: Build and sign Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
@@ -454,11 +479,12 @@ jobs:
|
||||
SSIGNCODE_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
|
||||
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
tagName: ${{ inputs.release_tag || github.ref_name }}
|
||||
releaseDraft: true
|
||||
# No assetNamePattern: the default Tauri CLI names already produce
|
||||
# DBX_[version]_x64/arm64-setup.exe and keep the _en-US.msi names.
|
||||
args: --target ${{ matrix.target }} --config src-tauri/tauri.vsign.conf.json
|
||||
# tauri.msi-version.conf.json is generated above and is empty for stable tags.
|
||||
args: --target ${{ matrix.target }} --config src-tauri/tauri.vsign.conf.json --config src-tauri/tauri.msi-version.conf.json
|
||||
|
||||
- name: Verify Windows executable stack reserve
|
||||
shell: pwsh
|
||||
@@ -492,7 +518,8 @@ jobs:
|
||||
SSIGNCODE_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
|
||||
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
|
||||
run: |
|
||||
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
|
||||
$tagVersion = "${env:RELEASE_TAG}".TrimStart("v")
|
||||
$version = $tagVersion -replace '-beta\..*$', ''
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$bundleDir = "target/${{ matrix.target }}/release/bundle/nsis"
|
||||
$offlineName = "DBX_${version}_${arch}-offline-setup.exe"
|
||||
@@ -517,7 +544,7 @@ jobs:
|
||||
|
||||
./.github/scripts/assert-authenticode.ps1 -Path $installer.FullName
|
||||
Copy-Item $installer.FullName $offlineName -Force
|
||||
gh release upload "${env:GITHUB_REF_NAME}" $offlineName --repo "${env:GITHUB_REPOSITORY}" --clobber
|
||||
gh release upload "${env:RELEASE_TAG}" $offlineName --repo "${env:GITHUB_REPOSITORY}" --clobber
|
||||
|
||||
- name: Upload Windows portable ZIP
|
||||
timeout-minutes: 20
|
||||
@@ -527,7 +554,8 @@ jobs:
|
||||
SSIGNCODE_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
|
||||
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
|
||||
run: |
|
||||
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
|
||||
$tagVersion = "${env:RELEASE_TAG}".TrimStart("v")
|
||||
$version = $tagVersion -replace '-beta\..*$', ''
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$portableRoot = "portable"
|
||||
$portableDir = Join-Path $portableRoot "DBX_${version}_${arch}"
|
||||
@@ -539,8 +567,8 @@ jobs:
|
||||
if (!(Test-Path $exePath)) {
|
||||
throw "Missing Windows executable: $exePath"
|
||||
}
|
||||
if (!$appVersion -or $appVersion -ne $version) {
|
||||
throw "Release tag version $version does not match the built DBX package version $appVersion"
|
||||
if (!$appVersion -or ($appVersion -ne $version -and $appVersion -ne $tagVersion)) {
|
||||
throw "Release tag version $tagVersion or base version $version does not match the built DBX package version $appVersion"
|
||||
}
|
||||
|
||||
./.github/scripts/vsign-sign.ps1 -FilePath $exePath
|
||||
@@ -576,7 +604,7 @@ jobs:
|
||||
throw "Missing portable update signature: $signatureName"
|
||||
}
|
||||
|
||||
gh release upload "${env:GITHUB_REF_NAME}" $zipName $signatureName --repo "${env:GITHUB_REPOSITORY}" --clobber
|
||||
gh release upload "${env:RELEASE_TAG}" $zipName $signatureName --repo "${env:GITHUB_REPOSITORY}" --clobber
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
@@ -609,16 +637,18 @@ jobs:
|
||||
# keeps the 1 MiB linker default and deep connect/serde call stacks abort
|
||||
# with 0xC00000FD ("DBX 已停止工作"). Guarded by assert-win7-pe-compat.ps1.
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static -C link-arg=/STACK:8388608
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1
|
||||
SCCACHE_IDLE_TIMEOUT: "0"
|
||||
VSIGN_CERT_HASH: ${{ vars.VSIGN_CERT_HASH }}
|
||||
VSIGN_OPERATOR_SHA1: ${{ vars.VSIGN_OPERATOR_SHA1 }}
|
||||
VSIGN_SERVER: ${{ vars.VSIGN_SERVER }}
|
||||
VSIGN_TOOL_SHA256: ${{ vars.VSIGN_TOOL_SHA256 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.release_tag || github.ref }}
|
||||
|
||||
- name: Set release version
|
||||
shell: pwsh
|
||||
run: node .github/scripts/set-release-version.mjs "${env:RELEASE_TAG}"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -637,31 +667,6 @@ jobs:
|
||||
toolchain: nightly-2026-10-25
|
||||
components: rust-src
|
||||
|
||||
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
|
||||
- name: Configure S3 sccache
|
||||
if: env.SCCACHE_GHA_ENABLED != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
|
||||
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
|
||||
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
|
||||
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare Win7-compatible WebView2 loader
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prepare-webview2-win7-loader.ps1
|
||||
@@ -714,15 +719,16 @@ jobs:
|
||||
SSIGNCODE_OPERATOR_PWD: ${{ secrets.VSIGN_OPERATOR_PWD }}
|
||||
VSIGN_KEY_PIN: ${{ secrets.VSIGN_KEY_PIN }}
|
||||
run: |
|
||||
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
|
||||
$tagVersion = "${env:RELEASE_TAG}".TrimStart("v")
|
||||
$version = $tagVersion -replace '-beta\..*$', ''
|
||||
$bundleDir = "target/x86_64-win7-windows-msvc/release/bundle/nsis"
|
||||
$offlineName = "DBX_${version}_x64-win7-server2012r2-offline-setup.exe"
|
||||
$checksumName = "${offlineName}.sha256"
|
||||
$cargoMetadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json
|
||||
$appVersion = ($cargoMetadata.packages | Where-Object { $_.name -eq "dbx" } | Select-Object -First 1).version
|
||||
|
||||
if (!$appVersion -or $appVersion -ne $version) {
|
||||
Write-Error "Release tag version $version does not match the built DBX package version $appVersion"
|
||||
if (!$appVersion -or ($appVersion -ne $version -and $appVersion -ne $tagVersion)) {
|
||||
Write-Error "Release tag version $tagVersion or base version $version does not match the built DBX package version $appVersion"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -744,14 +750,9 @@ jobs:
|
||||
Copy-Item $installer.FullName $offlineName -Force
|
||||
$hash = (Get-FileHash -LiteralPath $offlineName -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$hash $offlineName" | Set-Content -LiteralPath $checksumName -Encoding ascii
|
||||
gh release upload "${env:GITHUB_REF_NAME}" $offlineName $checksumName `
|
||||
gh release upload "${env:RELEASE_TAG}" $offlineName $checksumName `
|
||||
--repo "${env:GITHUB_REPOSITORY}" --clobber
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: ${SCCACHE_PATH} --show-stats
|
||||
|
||||
- name: Remove temporary VSign files
|
||||
if: always()
|
||||
shell: pwsh
|
||||
@@ -783,6 +784,12 @@ jobs:
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static -C link-arg=/STACK:8388608
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.release_tag || github.ref }}
|
||||
|
||||
- name: Set release version
|
||||
shell: pwsh
|
||||
run: node .github/scripts/set-release-version.mjs "${env:RELEASE_TAG}"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -826,7 +833,8 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$version = "${env:GITHUB_REF_NAME}".TrimStart("v")
|
||||
$tagVersion = "${env:RELEASE_TAG}".TrimStart("v")
|
||||
$version = $tagVersion -replace '-beta\..*$', ''
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$portableRoot = "portable"
|
||||
$portableDir = Join-Path $portableRoot "DBX_${version}_${arch}"
|
||||
@@ -850,7 +858,7 @@ jobs:
|
||||
$zip_sha256 = (Get-FileHash -LiteralPath $zipName -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Set-Content -Path $zipSha256 -Value $zip_sha256 -Encoding utf8NoBOM
|
||||
|
||||
gh release upload "${env:GITHUB_REF_NAME}" $zipName $zipSha256 --repo "${env:GITHUB_REPOSITORY}" --clobber
|
||||
gh release upload "${env:RELEASE_TAG}" $zipName $zipSha256 --repo "${env:GITHUB_REPOSITORY}" --clobber
|
||||
|
||||
static-browser:
|
||||
# Fully static musl builds of the browser (dbx-web) variant. No glibc
|
||||
@@ -878,6 +886,11 @@ jobs:
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.release_tag || github.ref }}
|
||||
|
||||
- name: Set release version
|
||||
run: node .github/scripts/set-release-version.mjs "${RELEASE_TAG}"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -979,10 +992,11 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
TAG_VERSION="${RELEASE_TAG#v}"
|
||||
VERSION="${TAG_VERSION%%-beta.*}"
|
||||
PACKAGE_VERSION="$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "dbx-web") | .version')"
|
||||
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
|
||||
echo "Release tag version $VERSION does not match dbx-web package version $PACKAGE_VERSION" >&2
|
||||
if [ "$PACKAGE_VERSION" != "$VERSION" ] && [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
|
||||
echo "Release tag version $TAG_VERSION or base version $VERSION does not match dbx-web package version $PACKAGE_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
SOURCE="dist-web-static/dbx-linux-${{ matrix.arch }}-browser-static.tar.gz"
|
||||
@@ -990,7 +1004,7 @@ jobs:
|
||||
CHECKSUM="${ASSET}.sha256"
|
||||
cp "$SOURCE" "$ASSET"
|
||||
sha256sum "$ASSET" | tee "$CHECKSUM"
|
||||
gh release upload "${GITHUB_REF_NAME}" "$ASSET" "$CHECKSUM" \
|
||||
gh release upload "${RELEASE_TAG}" "$ASSET" "$CHECKSUM" \
|
||||
--repo "${GITHUB_REPOSITORY}" --clobber
|
||||
|
||||
- name: Show sccache stats
|
||||
@@ -1008,7 +1022,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
mapfile -t SIG_ASSETS < <(
|
||||
gh release view "${GITHUB_REF_NAME}" \
|
||||
gh release view "${RELEASE_TAG}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--json assets \
|
||||
--jq '.assets[].name | select(endswith(".sig") and (endswith("-portable.zip.sig") | not))'
|
||||
@@ -1021,7 +1035,7 @@ jobs:
|
||||
|
||||
for ASSET in "${SIG_ASSETS[@]}"; do
|
||||
echo "Deleting release asset: ${ASSET}"
|
||||
gh release delete-asset "${GITHUB_REF_NAME}" "${ASSET}" \
|
||||
gh release delete-asset "${RELEASE_TAG}" "${ASSET}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--yes
|
||||
done
|
||||
@@ -1032,6 +1046,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.release_tag || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Java
|
||||
@@ -1059,7 +1074,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${GITHUB_REF_NAME}" \
|
||||
gh release upload "${RELEASE_TAG}" \
|
||||
"plugins/jdbc/dist/dbx-jdbc-plugin-${{ steps.jdbc-plugin.outputs.version }}.zip" \
|
||||
"plugins/jdbc/dist/dbx-jdbc-plugin-latest.zip" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
@@ -1071,7 +1086,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/latest-json"
|
||||
gh release download "${GITHUB_REF_NAME}" \
|
||||
gh release download "${RELEASE_TAG}" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern latest.json \
|
||||
--dir "$RUNNER_TEMP/latest-json"
|
||||
@@ -1080,7 +1095,7 @@ jobs:
|
||||
"${{ steps.jdbc-plugin.outputs.version }}" \
|
||||
"1" \
|
||||
"https://github.com/t8y2/dbx/releases/latest/download/dbx-jdbc-plugin-latest.zip"
|
||||
gh release upload "${GITHUB_REF_NAME}" \
|
||||
gh release upload "${RELEASE_TAG}" \
|
||||
"$RUNNER_TEMP/latest-json/latest.json" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--clobber
|
||||
@@ -1091,23 +1106,134 @@ jobs:
|
||||
# upload their release assets independently of publication.
|
||||
needs: [cleanup-release-signatures, docker-manifest, fnos-package, jdbc-plugin, static-browser]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
steps:
|
||||
- name: Publish draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release edit ${{ github.ref_name }} --repo ${{ github.repository }} --draft=false --prerelease
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Every release stays a prerelease until publish-packages.yml promotes it,
|
||||
# so GitHub's latest release never runs ahead of R2, Homebrew and Scoop.
|
||||
gh release edit "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --draft=false --prerelease
|
||||
|
||||
- name: Promote stable release through Publish Packages
|
||||
if: ${{ !contains(env.RELEASE_TAG, '-beta.') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Beta releases wait for an approved promotion; plain version tags are
|
||||
# published automatically, with no manual workflow run.
|
||||
gh workflow run publish-packages.yml \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--ref "${RELEASE_TAG}" \
|
||||
-f tag="${RELEASE_TAG}" \
|
||||
-f notify=true
|
||||
|
||||
publish-beta-manifest:
|
||||
name: Publish beta update manifest
|
||||
needs: publish
|
||||
if: startsWith(inputs.release_tag || github.ref_name, 'v') && contains(inputs.release_tag || github.ref_name, '-beta.')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.release_tag || github.ref }}
|
||||
|
||||
- name: Download updater manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release download "${{ inputs.release_tag || github.ref_name }}" --repo "${{ github.repository }}" --dir "$RUNNER_TEMP/manifest" --clobber
|
||||
|
||||
- name: Read release timestamp
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release view "${{ inputs.release_tag || github.ref_name }}" --repo "${{ github.repository }}" --json publishedAt --jq '"published_at=" + .publishedAt' >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate beta manifest completeness
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest="$RUNNER_TEMP/manifest/latest.json"
|
||||
test -s "$manifest"
|
||||
test -n "$(jq -r '.version // empty' "$manifest")"
|
||||
for platform in darwin-aarch64 darwin-x86_64 windows-x86_64 windows-aarch64 linux-x86_64 linux-aarch64; do
|
||||
jq -e --arg platform "$platform" '.platforms[$platform] | type == "object" and (.url | type == "string" and length > 0) and (.signature | type == "string" and length > 0)' "$manifest" >/dev/null
|
||||
done
|
||||
|
||||
- name: Prepare beta manifest
|
||||
run: node .github/scripts/prepare-channel-manifest.mjs "$RUNNER_TEMP/manifest/latest.json" "$RUNNER_TEMP/manifest/latest.json" beta "${{ inputs.release_tag || github.ref_name }}" false "${{ steps.release.outputs.published_at }}"
|
||||
|
||||
- name: Rewrite beta asset URLs to the beta R2 directory
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ inputs.release_tag || github.ref_name }}"
|
||||
manifest="$RUNNER_TEMP/manifest/latest.json"
|
||||
jq --arg tag "$TAG" '
|
||||
(.platforms[]?.url?) |= (
|
||||
sub("\\?.*$"; "")
|
||||
| if startswith("https://github.com/t8y2/dbx/releases/download/" + $tag + "/")
|
||||
then sub("https://github\\.com/t8y2/dbx/releases/download/" + $tag + "/"; "https://dl.dbxio.com/releases/beta/" + $tag + "/")
|
||||
else .
|
||||
end
|
||||
+ "?v=" + $tag
|
||||
)
|
||||
' "$manifest" > "$manifest.tmp"
|
||||
mv "$manifest.tmp" "$manifest"
|
||||
|
||||
- name: Publish beta manifest to R2
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
versioned="s3://${R2_BUCKET_NAME}/releases/beta/${{ inputs.release_tag || github.ref_name }}/"
|
||||
if aws s3api head-object --bucket "$R2_BUCKET_NAME" --key "releases/beta/${{ inputs.release_tag || github.ref_name }}/latest.json" --endpoint-url "$endpoint" >/dev/null 2>&1; then
|
||||
aws s3 cp "${versioned}latest.json" "$RUNNER_TEMP/existing-beta-manifest.json" --endpoint-url "$endpoint"
|
||||
if ! cmp -s "$RUNNER_TEMP/manifest/latest.json" "$RUNNER_TEMP/existing-beta-manifest.json"; then
|
||||
echo "::error::Immutable beta manifest already exists for ${{ inputs.release_tag || github.ref_name }} with different contents."
|
||||
exit 1
|
||||
fi
|
||||
echo "Beta manifest already exists with identical contents; continuing idempotently."
|
||||
else
|
||||
aws s3 cp "$RUNNER_TEMP/manifest/latest.json" "${versioned}latest.json" --endpoint-url "$endpoint" --cache-control "no-cache"
|
||||
fi
|
||||
# gh release download fetched the signed installers and portable
|
||||
# archives as well as latest.json. Keep those immutable assets beside
|
||||
# the versioned beta manifest so the manifest's R2 URLs are usable.
|
||||
aws s3 sync "$RUNNER_TEMP/manifest/" "$versioned" --endpoint-url "$endpoint" --cache-control "no-cache"
|
||||
aws s3 sync "$RUNNER_TEMP/manifest/" "s3://${R2_BUCKET_NAME}/releases/beta/latest/" --endpoint-url "$endpoint" --cache-control "no-cache" --delete
|
||||
aws s3 cp "$RUNNER_TEMP/manifest/latest.json" "s3://${R2_BUCKET_NAME}/releases/beta/latest.json" --endpoint-url "$endpoint" --cache-control "no-cache"
|
||||
|
||||
- name: Keep latest five beta releases in R2
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: bash .github/scripts/prune-r2-releases.sh beta "${{ inputs.release_tag || github.ref_name }}"
|
||||
|
||||
sync-release-to-cnb:
|
||||
name: Sync release to CNB
|
||||
needs: publish
|
||||
if: ${{ !contains(inputs.release_tag || github.ref_name, '-beta.') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.release_tag || github.ref }}
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
TAG_NAME: ${{ inputs.release_tag || github.ref_name }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
@@ -1135,7 +1261,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
|
||||
CURRENT_TAG: ${{ github.ref_name }}
|
||||
CURRENT_TAG: ${{ inputs.release_tag || github.ref_name }}
|
||||
run: |
|
||||
node .github/scripts/cleanup-cnb-releases.mjs \
|
||||
--current-tag "$CURRENT_TAG" \
|
||||
@@ -1151,6 +1277,8 @@ jobs:
|
||||
platform: [linux/amd64, linux/arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.release_tag || github.ref }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -1255,18 +1383,24 @@ jobs:
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "version=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create manifest list and push
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
dockerhub_tags=("-t" "${{ secrets.DOCKERHUB_USERNAME }}/dbx:${VERSION}")
|
||||
cnb_tags=("-t" "${{ env.CNB_REGISTRY }}/${{ env.CNB_REPOSITORY }}:${VERSION}")
|
||||
if [[ "${VERSION}" != *-beta.* ]]; then
|
||||
dockerhub_tags+=("-t" "${{ secrets.DOCKERHUB_USERNAME }}/dbx:latest")
|
||||
cnb_tags+=("-t" "${{ env.CNB_REGISTRY }}/${{ env.CNB_REPOSITORY }}:latest")
|
||||
fi
|
||||
|
||||
docker buildx imagetools create \
|
||||
-t ${{ secrets.DOCKERHUB_USERNAME }}/dbx:${{ steps.version.outputs.version }} \
|
||||
-t ${{ secrets.DOCKERHUB_USERNAME }}/dbx:latest \
|
||||
"${dockerhub_tags[@]}" \
|
||||
$(cd /tmp/digests/dockerhub && printf '${{ secrets.DOCKERHUB_USERNAME }}/dbx@sha256:%s ' *)
|
||||
|
||||
docker buildx imagetools create \
|
||||
-t ${{ env.CNB_REGISTRY }}/${{ env.CNB_REPOSITORY }}:${{ steps.version.outputs.version }} \
|
||||
-t ${{ env.CNB_REGISTRY }}/${{ env.CNB_REPOSITORY }}:latest \
|
||||
"${cnb_tags[@]}" \
|
||||
$(cd /tmp/digests/cnb && printf '${{ env.CNB_REGISTRY }}/${{ env.CNB_REPOSITORY }}@sha256:%s ' *)
|
||||
|
||||
fnos-package:
|
||||
@@ -1275,10 +1409,12 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.release_tag || github.ref }}
|
||||
|
||||
- name: Build versioned fnOS package
|
||||
env:
|
||||
DBX_FNOS_VERSION: ${{ github.ref_name }}
|
||||
DBX_FNOS_VERSION: ${{ inputs.release_tag || github.ref_name }}
|
||||
run: |
|
||||
DBX_FNOS_VERSION="${DBX_FNOS_VERSION#v}" ./deploy/fnos/build.sh
|
||||
|
||||
@@ -1286,6 +1422,6 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${GITHUB_REF_NAME}" dist/fnos/* \
|
||||
gh release upload "${RELEASE_TAG}" dist/fnos/* \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--clobber
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Rollback Stable Update Channel
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Previously promoted release tag to point the stable channel at"
|
||||
required: true
|
||||
type: string
|
||||
purge_cache:
|
||||
description: "Purge Cloudflare cache after switching aliases"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
rollback:
|
||||
name: Roll back stable update manifest
|
||||
runs-on: ubuntu-latest
|
||||
environment: release-promotion
|
||||
steps:
|
||||
- name: Validate stable release and manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-beta\.[0-9]{8}\.[0-9]+)?$ ]]; then
|
||||
echo "::error::Rollback requires a promoted vX.Y.Z or vX.Y.Z-beta.YYYYMMDD.N tag: $TAG"
|
||||
exit 1
|
||||
fi
|
||||
RELEASE_JSON="$(gh release view "$TAG" --repo "${GITHUB_REPOSITORY}" --json isDraft,isPrerelease)"
|
||||
if [ "$(jq -r '.isDraft' <<<"$RELEASE_JSON")" = "true" ] || [ "$(jq -r '.isPrerelease' <<<"$RELEASE_JSON")" = "true" ]; then
|
||||
echo "::error::$TAG is not a promoted stable release."
|
||||
exit 1
|
||||
fi
|
||||
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
mkdir -p rollback-assets
|
||||
aws s3 cp "s3://${R2_BUCKET_NAME}/releases/stable/${TAG}/latest.json" rollback-assets/latest.json \
|
||||
--endpoint-url "$endpoint"
|
||||
jq -e --arg tag "$TAG" '
|
||||
.channel == "stable" and .release_tag == $tag and .is_promoted == true and
|
||||
(.platforms | type == "object") and
|
||||
(. as $manifest |
|
||||
all(["darwin-aarch64", "darwin-x86_64", "windows-x86_64", "windows-aarch64", "linux-x86_64", "linux-aarch64"][];
|
||||
$manifest.platforms[.] | type == "object" and (.url | type == "string" and length > 0) and (.signature | type == "string" and length > 0)))
|
||||
' rollback-assets/latest.json >/dev/null
|
||||
|
||||
- name: Point stable aliases at the selected immutable manifest
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
source="s3://${R2_BUCKET_NAME}/releases/stable/${TAG}/"
|
||||
for alias in stable/latest latest; do
|
||||
aws s3 sync "$source" "s3://${R2_BUCKET_NAME}/releases/${alias}/" \
|
||||
--endpoint-url "$endpoint" --cache-control "no-cache" --delete
|
||||
done
|
||||
aws s3 cp "$source/latest.json" "s3://${R2_BUCKET_NAME}/releases/stable/latest.json" \
|
||||
--endpoint-url "$endpoint" --cache-control "no-cache"
|
||||
|
||||
- name: Purge Cloudflare cache
|
||||
if: ${{ inputs.purge_cache }}
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zone_id="${CLOUDFLARE_ZONE_ID:-}"
|
||||
if [ -z "$zone_id" ]; then
|
||||
zone_id="$(curl -fsSL -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" -H "Content-Type: application/json" \
|
||||
"https://api.cloudflare.com/client/v4/zones?name=dbxio.com" | jq -r '.result[0].id // empty')"
|
||||
fi
|
||||
test -n "$zone_id"
|
||||
curl -fsSL -X POST -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" -H "Content-Type: application/json" \
|
||||
--data '{"purge_everything":true}' "https://api.cloudflare.com/client/v4/zones/${zone_id}/purge_cache" \
|
||||
| jq -e '.success == true'
|
||||
@@ -91,22 +91,44 @@ jobs:
|
||||
cp updater-assets/latest.json cnb-assets/latest.json
|
||||
gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,name,body,targetCommitish,isPrerelease,isDraft > updater-release.json
|
||||
CHANNEL="stable"
|
||||
if [ "$(jq -r '.isPrerelease' updater-release.json)" = "true" ]; then
|
||||
CHANNEL="beta"
|
||||
fi
|
||||
node .github/scripts/sync-cnb-release.mjs \
|
||||
--github-release updater-release.json \
|
||||
--assets-dir cnb-assets \
|
||||
--overwrite-existing
|
||||
|
||||
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
# Derive the R2 manifest from the GitHub release latest.json (notes already
|
||||
# injected above) instead of re-reading the R2 copy, so every sync also
|
||||
# heals a stale or partial R2 manifest.
|
||||
jq --arg tag "$TAG" '
|
||||
# Start from the immutable channel manifest so changelog refreshes keep
|
||||
# the release metadata and platform assets produced by the release job.
|
||||
aws s3 cp "s3://${R2_BUCKET_NAME}/releases/${CHANNEL}/${TAG}/latest.json" r2-versioned.json \
|
||||
--endpoint-url "$ENDPOINT" --cache-control no-cache
|
||||
jq --arg tag "$TAG" --arg channel "$CHANNEL" --arg notes "$NOTES" '
|
||||
.notes = $notes |
|
||||
(.platforms[]?.url?) |= (
|
||||
sub("https://github\\.com/t8y2/dbx/releases/download/" + $tag + "/"; "https://dl.dbxio.com/releases/latest/")
|
||||
sub("\\?.*$"; "")
|
||||
| if startswith("https://github.com/t8y2/dbx/releases/download/" + $tag + "/")
|
||||
then sub("https://github\\.com/t8y2/dbx/releases/download/" + $tag + "/"; "https://dl.dbxio.com/releases/" + $channel + "/" + $tag + "/")
|
||||
else .
|
||||
end
|
||||
+ "?v=" + $tag
|
||||
)
|
||||
' updater-assets/latest.json > r2-latest.json
|
||||
aws s3 cp r2-latest.json "s3://${R2_BUCKET_NAME}/releases/${TAG}/latest.json" \
|
||||
--endpoint-url "$ENDPOINT" --content-type application/json --cache-control no-cache
|
||||
aws s3 cp r2-latest.json "s3://${R2_BUCKET_NAME}/releases/latest/latest.json" \
|
||||
' r2-versioned.json > r2-latest.json
|
||||
# Versioned manifests are immutable history. Release/promotion creates
|
||||
# that object; changelog refreshes only the channel's moving aliases.
|
||||
aws s3api head-object \
|
||||
--bucket "$R2_BUCKET_NAME" \
|
||||
--key "releases/${CHANNEL}/${TAG}/latest.json" \
|
||||
--endpoint-url "$ENDPOINT" >/dev/null
|
||||
aws s3 cp r2-latest.json "s3://${R2_BUCKET_NAME}/releases/${CHANNEL}/latest/latest.json" \
|
||||
--endpoint-url "$ENDPOINT" --content-type application/json --cache-control no-cache
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
for alias in latest; do
|
||||
aws s3 cp r2-latest.json "s3://${R2_BUCKET_NAME}/releases/${alias}/latest.json" \
|
||||
--endpoint-url "$ENDPOINT" --content-type application/json --cache-control no-cache
|
||||
done
|
||||
aws s3 cp r2-latest.json "s3://${R2_BUCKET_NAME}/releases/stable/latest.json" \
|
||||
--endpoint-url "$ENDPOINT" --content-type application/json --cache-control no-cache
|
||||
fi
|
||||
|
||||
Generated
+1
@@ -2631,6 +2631,7 @@ dependencies = [
|
||||
"futures",
|
||||
"nix 0.31.3",
|
||||
"reqwest 0.12.28",
|
||||
"semver",
|
||||
"serde",
|
||||
"tokio",
|
||||
"uuid",
|
||||
|
||||
Generated
+1
@@ -1596,6 +1596,7 @@ dependencies = [
|
||||
"nix",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"semver",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -68,7 +68,7 @@ const backupSettingsCategoryLabelKeys: Record<BackupSettingsCategoryId, string>
|
||||
shortcuts: "settings.shortcutsTab",
|
||||
snippets: "settings.snippetsTab",
|
||||
};
|
||||
const nonSyncableEditorSettingIds = new Set(["updateNotificationsEnabled", "autoDownloadUpdates", "autoUpdateApp", "autoUpdateDrivers", "autoUpdateJdbc", "autoUpdateMcp", "autoUpdatePlugins", "updateDownloadSource", "ignoredUpdateVersion"]);
|
||||
const nonSyncableEditorSettingIds = new Set(["updateNotificationsEnabled", "autoDownloadUpdates", "autoUpdateApp", "autoUpdateDrivers", "autoUpdateJdbc", "autoUpdateMcp", "autoUpdatePlugins", "updateDownloadSource", "updateChannel", "ignoredUpdateVersion"]);
|
||||
const desktopSettingCategories: Record<string, BackupSettingsCategoryId> = {
|
||||
show_tray_icon: "appearance",
|
||||
linux_floating_frame_enabled: "appearance",
|
||||
|
||||
@@ -113,6 +113,7 @@ import {
|
||||
type TabSortMode,
|
||||
type WelcomePageMode,
|
||||
type UpdateDownloadSource,
|
||||
type UpdateChannel,
|
||||
type CsvQuoteMode,
|
||||
type CsvNullMode,
|
||||
type QueryResultExportFileNameSource,
|
||||
@@ -1082,6 +1083,7 @@ const editExportRowLimitEnabled = ref(settingsStore.editorSettings.exportRowLimi
|
||||
const editExportRowLimit = ref(settingsStore.editorSettings.exportRowLimit);
|
||||
const editQueryExportKeysetOptimizationEnabled = ref(settingsStore.editorSettings.queryExportKeysetOptimizationEnabled);
|
||||
const editUpdateDownloadSource = ref<UpdateDownloadSource>(settingsStore.editorSettings.updateDownloadSource);
|
||||
const editUpdateChannel = ref<UpdateChannel>(settingsStore.editorSettings.updateChannel);
|
||||
const editToolbarItems = ref({ ...settingsStore.editorSettings.toolbarItems });
|
||||
// Desktop-only toolbar buttons (always-on-top) can never render in the Web build, so their switches stay out of the Web settings dialog.
|
||||
const toolbarVisibilityItems = computed(() => visibleToolbarVisibilityItems(TOOLBAR_VISIBILITY_ITEMS, isWeb));
|
||||
@@ -1392,6 +1394,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
|
||||
exportRowLimit: editExportRowLimit.value,
|
||||
queryExportKeysetOptimizationEnabled: editQueryExportKeysetOptimizationEnabled.value,
|
||||
updateDownloadSource: editUpdateDownloadSource.value,
|
||||
updateChannel: editUpdateChannel.value,
|
||||
toolbarItems: { ...editToolbarItems.value },
|
||||
snippets: editSnippets.value,
|
||||
snippetTriggerKey: editSnippetTriggerKey.value,
|
||||
@@ -2098,6 +2101,7 @@ function syncEditorSettingsDraftFromStore() {
|
||||
editExportRowLimit.value = settingsStore.editorSettings.exportRowLimit;
|
||||
editQueryExportKeysetOptimizationEnabled.value = settingsStore.editorSettings.queryExportKeysetOptimizationEnabled;
|
||||
editUpdateDownloadSource.value = settingsStore.editorSettings.updateDownloadSource;
|
||||
editUpdateChannel.value = settingsStore.editorSettings.updateChannel;
|
||||
editToolbarItems.value = { ...settingsStore.editorSettings.toolbarItems };
|
||||
editSnippets.value = settingsStore.editorSettings.snippets.map(editableSnippet);
|
||||
editSnippetTriggerKey.value = settingsStore.editorSettings.snippetTriggerKey;
|
||||
@@ -2274,6 +2278,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = {
|
||||
globalDateTimeExportFormat: editGlobalDateTimeExportFormat,
|
||||
globalDateTimeImportFormat: editGlobalDateTimeImportFormat,
|
||||
updateDownloadSource: editUpdateDownloadSource,
|
||||
updateChannel: editUpdateChannel,
|
||||
toolbarItems: editToolbarItems,
|
||||
snippets: editSnippets,
|
||||
snippetTriggerKey: editSnippetTriggerKey,
|
||||
@@ -2931,6 +2936,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
||||
editAutoUpdateMcp.value = DEFAULT_EDITOR_SETTINGS.autoUpdateMcp;
|
||||
editAutoUpdatePlugins.value = DEFAULT_EDITOR_SETTINGS.autoUpdatePlugins;
|
||||
editUpdateDownloadSource.value = DEFAULT_EDITOR_SETTINGS.updateDownloadSource;
|
||||
editUpdateChannel.value = DEFAULT_EDITOR_SETTINGS.updateChannel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3101,6 +3107,7 @@ function resetAllDefaults() {
|
||||
editExportRowLimit.value = DEFAULT_EDITOR_SETTINGS.exportRowLimit;
|
||||
editQueryExportKeysetOptimizationEnabled.value = DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled;
|
||||
editUpdateDownloadSource.value = DEFAULT_EDITOR_SETTINGS.updateDownloadSource;
|
||||
editUpdateChannel.value = DEFAULT_EDITOR_SETTINGS.updateChannel;
|
||||
editToolbarItems.value = { ...DEFAULT_EDITOR_SETTINGS.toolbarItems };
|
||||
editSnippets.value = DEFAULT_SQL_SNIPPETS.map((s) => ({ ...s }));
|
||||
editSnippetTriggerKey.value = DEFAULT_EDITOR_SETTINGS.snippetTriggerKey;
|
||||
@@ -3417,6 +3424,10 @@ function onUpdateDownloadSourceChange(v: any) {
|
||||
if (v === "official" || v === "cnb") editUpdateDownloadSource.value = v;
|
||||
}
|
||||
|
||||
function onUpdateChannelChange(v: any) {
|
||||
if (v === "stable" || v === "beta") editUpdateChannel.value = v;
|
||||
}
|
||||
|
||||
function setSidebarObjectDisplay(value: "grouped" | "simple") {
|
||||
editSidebarObjectDisplay.value = value;
|
||||
}
|
||||
@@ -13621,6 +13632,20 @@ LIMIT 100;</pre>
|
||||
<p class="border-t pt-3 text-xs text-muted-foreground">{{ t("settings.updateRestartHint") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<Label>{{ t("settings.updateChannel") }}</Label>
|
||||
<p class="text-sm text-muted-foreground">{{ t("settings.updateChannelDescription") }}</p>
|
||||
</div>
|
||||
<Select :model-value="editUpdateChannel" @update:model-value="onUpdateChannelChange">
|
||||
<SelectTrigger class="h-9 w-full sm:w-[180px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stable">{{ t("settings.updateChannelStable") }}</SelectItem>
|
||||
<SelectItem value="beta">{{ t("settings.updateChannelBeta") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<Label>{{ t("settings.updateDownloadSource") }}</Label>
|
||||
|
||||
@@ -183,6 +183,81 @@ describe("UpdateDialog web runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateDialog update channels", () => {
|
||||
it("marks beta updates and explains when switching back cannot downgrade", async () => {
|
||||
await mountDialog(
|
||||
0,
|
||||
{},
|
||||
vi.fn(async () => {}),
|
||||
{
|
||||
updateInfo: {
|
||||
current_version: "0.6.60-beta.20261028.1",
|
||||
latest_version: "0.6.59",
|
||||
update_available: false,
|
||||
portable_mode: false,
|
||||
manual_update_only: false,
|
||||
release_name: "DBX v0.6.59",
|
||||
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.6.59",
|
||||
release_notes: "",
|
||||
channel: "stable",
|
||||
release_tag: "v0.6.59",
|
||||
is_promoted: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(document.body.textContent).toContain("stable channel is older");
|
||||
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
await mountDialog(
|
||||
0,
|
||||
{},
|
||||
vi.fn(async () => {}),
|
||||
{
|
||||
updateInfo: {
|
||||
current_version: "0.6.60-beta.20261028.1",
|
||||
latest_version: "0.6.60",
|
||||
update_available: false,
|
||||
portable_mode: false,
|
||||
manual_update_only: false,
|
||||
release_name: "DBX v0.6.60",
|
||||
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.6.60",
|
||||
release_notes: "",
|
||||
channel: "stable",
|
||||
release_tag: "v0.6.60",
|
||||
is_promoted: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(document.body.textContent).not.toContain("stable channel is older");
|
||||
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
await mountDialog(
|
||||
0,
|
||||
{},
|
||||
vi.fn(async () => {}),
|
||||
{
|
||||
updateInfo: {
|
||||
current_version: "0.6.59",
|
||||
latest_version: "0.6.60-beta.20261028.1",
|
||||
update_available: true,
|
||||
portable_mode: false,
|
||||
manual_update_only: false,
|
||||
release_name: "DBX v0.6.60-beta.20261028.1",
|
||||
release_url: "https://github.com/t8y2/dbx/releases/tag/v0.6.60-beta.20261028.1",
|
||||
release_notes: "",
|
||||
channel: "beta",
|
||||
release_tag: "v0.6.60-beta.20261028.1",
|
||||
is_promoted: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(document.body.textContent).toContain("Dev Beta channel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateDialog active task guard", () => {
|
||||
it("shows the task warning but still allows starting a background download while work is running", async () => {
|
||||
await mountDialog(2);
|
||||
|
||||
@@ -110,11 +110,18 @@ const historyRequest = createLatestRequestGuard();
|
||||
const fallbackVersion = ref("");
|
||||
const changelogLang = computed(() => changelogLangFromLocale(locale.value || currentLocale()));
|
||||
const currentVersion = computed(() => props.updateInfo?.current_version || fallbackVersion.value);
|
||||
const stableDowngradeBlocked = computed(() => {
|
||||
const current = currentVersion.value.trim().replace(/^v/i, "");
|
||||
const latest = (props.updateInfo?.latest_version ?? "").trim().replace(/^v/i, "");
|
||||
const currentBase = current.split(/[+-]/, 1)[0];
|
||||
const latestBase = latest.split(/[+-]/, 1)[0];
|
||||
return props.updateInfo?.channel === "stable" && current.includes("-") && !props.updateInfo.update_available && Boolean(currentBase && latestBase) && isNewerRemoteVersion(currentBase, latestBase);
|
||||
});
|
||||
// 只保留低于当前版本的发布记录:新版本走“客户端更新”,这里专用于回退
|
||||
const olderReleases = computed(() => {
|
||||
const current = currentVersion.value;
|
||||
if (!current) return [];
|
||||
return historyReleases.value.filter((release) => isNewerRemoteVersion(current, release.tag));
|
||||
return historyReleases.value.filter((release) => !release.tag.includes("-beta.") && isNewerRemoteVersion(current, release.tag));
|
||||
});
|
||||
const visibleOlderReleases = computed(() => olderReleases.value.slice(0, historyVisibleCount.value));
|
||||
const hasMoreOlderReleases = computed(() => historyVisibleCount.value < olderReleases.value.length);
|
||||
@@ -370,6 +377,12 @@ watch(
|
||||
<ChangelogPanel v-if="selectedTab === 'changelog'" :checking-updates="checkingUpdates || isInstallingUpdate" @check-updates="emit('check-updates')" />
|
||||
<template v-else-if="selectedTab === 'app'">
|
||||
<div class="space-y-3">
|
||||
<div v-if="updateInfo?.channel === 'beta'" class="inline-flex rounded-full bg-amber-500/15 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||
{{ t("updates.betaChannelBadge") }}
|
||||
</div>
|
||||
<p v-if="stableDowngradeBlocked" class="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
{{ t("updates.stableDowngradeBlocked") }}
|
||||
</p>
|
||||
<p v-if="updateInfo?.update_available">
|
||||
{{
|
||||
t("updates.availableMessage", {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { createApp, defineComponent, h, reactive, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import { effectiveUpdateInfo, isNewerRemoteVersion, isPreparedUpdateObsolete, resolveReleaseTagUrl, useAppUpdater } from "@/composables/useAppUpdater";
|
||||
import { effectiveUpdateInfo, isNewerRemoteVersion, isPreparedUpdateObsolete, resolveReleaseTagUrl, resolveUpdateReleaseUrl, useAppUpdater } from "@/composables/useAppUpdater";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkForUpdates: vi.fn(),
|
||||
downloadUpdate: vi.fn(),
|
||||
@@ -20,7 +20,7 @@ vi.mock("@/lib/backend/api", () => mocks);
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
const settings = reactive({ updateDownloadSource: "official", ignoredUpdateVersion: "", updateNotificationsEnabled: true, autoDownloadUpdates: true, autoUpdateApp: true, toolbarItems: { checkUpdates: true } });
|
||||
const settings = reactive({ updateDownloadSource: "official", updateChannel: "stable", ignoredUpdateVersion: "", updateNotificationsEnabled: true, autoDownloadUpdates: true, autoUpdateApp: true, toolbarItems: { checkUpdates: true } });
|
||||
vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: settings, updateEditorSettingsAndPersist: mocks.persist }) }));
|
||||
const info = { current_version: "1.0.0", latest_version: "1.1.0", update_available: true, portable_mode: false, manual_update_only: false, release_name: "v1.1.0", release_url: "https://example.com", release_notes: "Changes" };
|
||||
const cache = { cache_id: "cached", version: "1.1.0", portable_mode: false, release_url: "https://example.com", release_notes: "Changes", downloaded_at: 1 };
|
||||
@@ -56,6 +56,7 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubEnv("VITE_DBX_DISABLE_UPDATER", "");
|
||||
settings.updateDownloadSource = "official";
|
||||
settings.updateChannel = "stable";
|
||||
settings.ignoredUpdateVersion = "";
|
||||
settings.updateNotificationsEnabled = true;
|
||||
settings.autoDownloadUpdates = true;
|
||||
@@ -74,6 +75,15 @@ afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
describe("older version release pages", () => {
|
||||
it("opens the GitHub beta release before it has been mirrored to CNB", () => {
|
||||
const releaseUrl = "https://github.com/t8y2/dbx/releases/tag/v1.1.0-beta.20261028.1";
|
||||
expect(resolveUpdateReleaseUrl({ ...info, channel: "beta", release_tag: "v1.1.0-beta.20261028.1", release_url: releaseUrl }, "cnb", "fallback")).toBe(releaseUrl);
|
||||
});
|
||||
|
||||
it("uses the original beta tag on CNB after promotion to stable", () => {
|
||||
expect(resolveUpdateReleaseUrl({ ...info, channel: "stable", release_tag: "v1.1.0-beta.20261028.1" }, "cnb", "fallback")).toBe("https://cnb.cool/dbxio.com/dbx/-/releases/tag/v1.1.0-beta.20261028.1");
|
||||
});
|
||||
|
||||
it("maps a historical version to the matching GitHub release page", () => {
|
||||
expect(resolveReleaseTagUrl("0.5.59", "official")).toBe("https://github.com/t8y2/dbx/releases/tag/v0.5.59");
|
||||
expect(resolveReleaseTagUrl("v0.5.59", "official")).toBe("https://github.com/t8y2/dbx/releases/tag/v0.5.59");
|
||||
@@ -83,6 +93,15 @@ describe("older version release pages", () => {
|
||||
expect(resolveReleaseTagUrl("v0.5.59", "cnb")).toBe("https://cnb.cool/dbxio.com/dbx/-/releases/tag/v0.5.59");
|
||||
});
|
||||
|
||||
it("passes the beta release tag through to the download cache", async () => {
|
||||
settings.updateChannel = "beta";
|
||||
const betaInfo = { ...info, channel: "beta" as const, release_tag: "v1.1.0-beta.20261028.1" };
|
||||
mocks.checkForUpdates.mockResolvedValue(betaInfo);
|
||||
const updater = mount();
|
||||
await updater.checkUpdates({ silent: true });
|
||||
expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.1.0", expect.any(String), "Changes", "beta", "v1.1.0-beta.20261028.1");
|
||||
});
|
||||
|
||||
it("treats only strictly older releases as rollback candidates", () => {
|
||||
expect(isNewerRemoteVersion("0.5.60", "v0.5.59")).toBe(true);
|
||||
expect(isNewerRemoteVersion("0.5.60", "v0.5.60")).toBe(false);
|
||||
@@ -97,10 +116,28 @@ describe("prepared update reconciliation (#13562)", () => {
|
||||
expect(isPreparedUpdateObsolete("0.5.61", "0.5.60")).toBe(false);
|
||||
expect(isPreparedUpdateObsolete("", "0.5.60")).toBe(false);
|
||||
expect(isPreparedUpdateObsolete("0.5.61", "")).toBe(false);
|
||||
expect(isPreparedUpdateObsolete("0.6.60-beta.20261029.1", "0.6.60", "beta")).toBe(false);
|
||||
expect(isPreparedUpdateObsolete("0.6.60-beta.20261029.1", "0.6.60", "stable")).toBe(true);
|
||||
expect(isPreparedUpdateObsolete("0.6.60", "0.6.60-beta.20261029.1", "stable")).toBe(true);
|
||||
expect(isPreparedUpdateObsolete("0.6.61", "0.6.60-beta.20261029.1", "stable")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("silent update lifecycle", () => {
|
||||
it("discards the promoted stable cache when its beta binary is already running", async () => {
|
||||
mocks.getAppVersion.mockResolvedValue("1.1.0-beta.20261029.1");
|
||||
mocks.getDownloadedUpdate.mockResolvedValue(cache);
|
||||
mocks.checkForUpdates.mockResolvedValue({ ...info, current_version: "1.1.0-beta.20261029.1", channel: "stable", update_available: false });
|
||||
const updater = mount();
|
||||
await updater.initialize();
|
||||
await flush();
|
||||
|
||||
expect(mocks.discardDownloadedUpdate).toHaveBeenCalledWith(cache.cache_id);
|
||||
expect(updater.updateDownloaded.value).toBe(false);
|
||||
expect(updater.hasUpdateAvailable.value).toBe(false);
|
||||
expect(mocks.downloadUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not check or download updates when disabled for an isolated dev client", async () => {
|
||||
vi.stubEnv("VITE_DBX_DISABLE_UPDATER", "1");
|
||||
const updater = mount();
|
||||
@@ -149,7 +186,7 @@ describe("silent update lifecycle", () => {
|
||||
expect(mocks.installDownloadedUpdate).not.toHaveBeenCalled();
|
||||
mocks.downloadUpdate.mockResolvedValue({ ...cache, version: "1.2.0" });
|
||||
await updater.downloadUpdateInBackground();
|
||||
expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.2.0", expect.any(String), "Changes");
|
||||
expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.2.0", expect.any(String), "Changes", "stable");
|
||||
expect(updater.updateDownloaded.value).toBe(true);
|
||||
});
|
||||
it("preserves a prepared package with automatic downloads disabled", async () => {
|
||||
@@ -209,7 +246,7 @@ describe("silent update lifecycle", () => {
|
||||
it("automatically downloads without surfacing or installing, even while idle", async () => {
|
||||
const updater = mount();
|
||||
await updater.checkUpdates({ silent: true });
|
||||
expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.1.0", expect.any(String), "Changes");
|
||||
expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.1.0", expect.any(String), "Changes", "stable");
|
||||
expect(updater.phase.value).toBe("ready");
|
||||
expect(updater.hasUpdateAvailable.value).toBe(true);
|
||||
expect(updater.showUpdateDialog.value).toBe(false);
|
||||
@@ -346,6 +383,26 @@ describe("silent update lifecycle", () => {
|
||||
expect(mocks.downloadUpdate).toHaveBeenCalledOnce();
|
||||
expect(mocks.checkForUpdates).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("rechecks immediately when the persisted update channel changes", async () => {
|
||||
mocks.checkForUpdates.mockResolvedValue({ ...info, update_available: false });
|
||||
const updater = mount();
|
||||
await updater.initialize();
|
||||
mocks.checkForUpdates.mockClear();
|
||||
|
||||
settings.updateChannel = "beta";
|
||||
await vi.waitFor(() => expect(mocks.checkForUpdates).toHaveBeenCalled());
|
||||
|
||||
expect(mocks.checkForUpdates).toHaveBeenLastCalledWith("en", "official", "beta");
|
||||
});
|
||||
it("discards a cached update from another channel before checking", async () => {
|
||||
mocks.checkForUpdates.mockResolvedValue({ ...info, update_available: false });
|
||||
mocks.getDownloadedUpdate.mockResolvedValue({ ...cache, channel: "beta" });
|
||||
const updater = mount();
|
||||
await updater.initialize();
|
||||
|
||||
expect(mocks.discardDownloadedUpdate).toHaveBeenCalledWith(cache.cache_id);
|
||||
expect(updater.updateDownloaded.value).toBe(false);
|
||||
});
|
||||
it("reconciles a download that commits just before cancellation without installing it", async () => {
|
||||
const pending = deferred<typeof cache>();
|
||||
mocks.downloadUpdate.mockReturnValue(pending.promise);
|
||||
@@ -447,7 +504,7 @@ describe("silent update lifecycle", () => {
|
||||
await updater.initialize();
|
||||
await vi.waitFor(() => expect(mocks.downloadUpdate).toHaveBeenCalled());
|
||||
expect(mocks.discardDownloadedUpdate).toHaveBeenCalledWith("cached");
|
||||
expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.2.0", expect.any(String), "Changes");
|
||||
expect(mocks.downloadUpdate).toHaveBeenCalledWith("official", "1.2.0", expect.any(String), "Changes", "stable");
|
||||
expect(updater.updateDownloaded.value).toBe(true);
|
||||
expect(updater.updateInfo.value?.latest_version).toBe("1.2.0");
|
||||
expect(updater.phase.value).toBe("ready");
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useI18n } from "vue-i18n";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import type { UpdateDownloadSource as SettingsUpdateDownloadSource } from "@/stores/settingsStore";
|
||||
import type { UpdateChannel as SettingsUpdateChannel, UpdateDownloadSource as SettingsUpdateDownloadSource } from "@/stores/settingsStore";
|
||||
import type { UpdateDownloadProgress } from "@/lib/backend/tauri";
|
||||
import { currentLocale } from "@/i18n";
|
||||
import { shouldBlockAppUpdate } from "@/lib/app/appUpdateTaskGuard";
|
||||
@@ -107,8 +107,16 @@ export function isNewerRemoteVersion(latest: string, cached: string): boolean {
|
||||
* update cache, and the badge then advertises a version the app already runs
|
||||
* (#13562). An unknown version is never treated as obsolete.
|
||||
*/
|
||||
export function isPreparedUpdateObsolete(preparedVersion: string, currentVersion: string): boolean {
|
||||
export function isPreparedUpdateObsolete(preparedVersion: string, currentVersion: string, channel: SettingsUpdateChannel = "stable"): boolean {
|
||||
if (!preparedVersion || !currentVersion) return false;
|
||||
const prepared = parseUpdateVersion(preparedVersion);
|
||||
const current = parseUpdateVersion(currentVersion);
|
||||
if (prepared && current && prepared.core.join(".") === current.core.join(".")) {
|
||||
if (channel === "beta" && prepared.prerelease.length && !current.prerelease.length) return false;
|
||||
// Promotion keeps the beta binary, so a stable manifest with the same
|
||||
// base version does not represent a package the running build needs.
|
||||
if (channel === "stable" && !prepared.prerelease.length && current.prerelease.length) return true;
|
||||
}
|
||||
return !isNewerRemoteVersion(preparedVersion, currentVersion);
|
||||
}
|
||||
|
||||
@@ -124,9 +132,12 @@ export function tagVersion(version: string): string {
|
||||
}
|
||||
|
||||
export function resolveUpdateReleaseUrl(info: api.UpdateInfo | null, source: unknown, fallbackUrl: string): string {
|
||||
// Beta releases are mirrored to CNB only after promotion to stable.
|
||||
if (info?.channel === "beta") return info.release_url || fallbackUrl;
|
||||
const normalizedSource = normalizeUpdateDownloadSource(source);
|
||||
if (normalizedSource === "cnb" && info?.latest_version) {
|
||||
return `https://cnb.cool/dbxio.com/dbx/-/releases/tag/${tagVersion(info.latest_version)}`;
|
||||
if (normalizedSource === "cnb" && (info?.release_tag || info?.latest_version)) {
|
||||
const tag = info.release_tag ?? tagVersion(info.latest_version);
|
||||
return `https://cnb.cool/dbxio.com/dbx/-/releases/tag/${encodeURIComponent(tag)}`;
|
||||
}
|
||||
if (normalizedSource === "cnb") return "https://cnb.cool/dbxio.com/dbx/-/releases";
|
||||
return info?.release_url || fallbackUrl;
|
||||
@@ -176,6 +187,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
|
||||
const updateReady = computed(() => phase.value === "restart");
|
||||
const activeTaskCount = computed(() => Math.max(0, Math.trunc(options.getActiveTaskCount?.() ?? 0)));
|
||||
const autoUpdateEnabled = computed(() => settingsStore.editorSettings.autoUpdateApp !== false);
|
||||
const updateChannel = computed<SettingsUpdateChannel>(() => settingsStore.editorSettings.updateChannel ?? "stable");
|
||||
// Hiding the toolbar update entry opts the app out of background update
|
||||
// nagging (#11041): silent startup/hourly/retry checks stop, while the
|
||||
// explicit "Check for updates" action in settings keeps working.
|
||||
@@ -230,6 +242,9 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
|
||||
release_name: tagVersion(cache.version),
|
||||
release_url: cache.release_url,
|
||||
release_notes: cache.release_notes,
|
||||
channel: cache.channel ?? "stable",
|
||||
release_tag: cache.release_tag ?? tagVersion(cache.version),
|
||||
is_promoted: cache.is_promoted ?? cache.channel !== "beta",
|
||||
};
|
||||
phase.value = "ready";
|
||||
downloadProgress.value = 100;
|
||||
@@ -276,7 +291,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
|
||||
phase.value = "idle";
|
||||
return;
|
||||
}
|
||||
const info = await api.checkForUpdates(currentLocale(), normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource));
|
||||
const info = await api.checkForUpdates(currentLocale(), normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource), updateChannel.value);
|
||||
if (token !== generation || disposed) return;
|
||||
updateInfo.value = info;
|
||||
// Installation may have started while the check was in flight; only restore
|
||||
@@ -290,7 +305,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
|
||||
// and the dialog claims an update is downloaded with no way to install
|
||||
// it (#13562).
|
||||
const prepared = downloaded.value;
|
||||
if (prepared && isPreparedUpdateObsolete(prepared.version, info.current_version)) {
|
||||
if (prepared && isPreparedUpdateObsolete(prepared.version, info.current_version, info.channel)) {
|
||||
await discardSupersededUpdate(prepared);
|
||||
if (token !== generation || disposed) return;
|
||||
}
|
||||
@@ -351,7 +366,9 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
|
||||
downloadProgress.value = payload.total && payload.total > 0 ? Math.min(100, Math.round((payload.downloaded / payload.total) * 100)) : null;
|
||||
});
|
||||
if (token !== generation || disposed) return;
|
||||
const cache = await api.downloadUpdate(normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource), version, attemptId, updateInfo.value?.release_notes);
|
||||
const source = normalizeUpdateDownloadSource(settingsStore.editorSettings.updateDownloadSource);
|
||||
const releaseTag = updateInfo.value?.release_tag;
|
||||
const cache = releaseTag ? await api.downloadUpdate(source, version, attemptId, updateInfo.value?.release_notes, updateChannel.value, releaseTag) : await api.downloadUpdate(source, version, attemptId, updateInfo.value?.release_notes, updateChannel.value);
|
||||
if (token !== generation || disposed) return;
|
||||
if (cache.version !== version) throw new Error("Downloaded update version changed; check for updates again.");
|
||||
setDownloaded(cache);
|
||||
@@ -415,6 +432,21 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
async function changeUpdateChannel(channel: SettingsUpdateChannel) {
|
||||
if (isIgnoringUpdate.value || isInstallingUpdate.value) return;
|
||||
try {
|
||||
await cancelDownload();
|
||||
const cache = downloaded.value;
|
||||
downloaded.value = null;
|
||||
updateInfo.value = null;
|
||||
phase.value = "idle";
|
||||
if (cache) await api.discardDownloadedUpdate(cache.cache_id);
|
||||
if (updateChannel.value !== channel) await settingsStore.updateEditorSettingsAndPersist({ updateChannel: channel });
|
||||
await checkUpdates();
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
async function ignoreCurrentVersion() {
|
||||
const version = updateInfo.value?.latest_version;
|
||||
if (!version || isIgnoringUpdate.value || isInstallingUpdate.value || updateReady.value) return;
|
||||
@@ -503,16 +535,20 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
|
||||
const cache = await api.getDownloadedUpdate();
|
||||
if (disposed) return;
|
||||
if (cache) {
|
||||
const ignored = isUpdateIgnored({ latest_version: cache.version } as api.UpdateInfo, settingsStore.editorSettings.ignoredUpdateVersion);
|
||||
if (ignored) await api.discardDownloadedUpdate(cache.cache_id);
|
||||
else {
|
||||
setDownloaded(cache);
|
||||
updateInfo.value!.current_version = await api.getAppVersion();
|
||||
// An upgrade that happened outside this process leaves the previous
|
||||
// package in the cache. Adopting it would pin the dialog to "already
|
||||
// up to date" while still advertising a downloaded update (#13562).
|
||||
if (isPreparedUpdateObsolete(cache.version, updateInfo.value!.current_version)) {
|
||||
await discardSupersededUpdate(cache);
|
||||
if ((cache.channel ?? "stable") !== updateChannel.value) {
|
||||
await api.discardDownloadedUpdate(cache.cache_id);
|
||||
} else {
|
||||
const ignored = isUpdateIgnored({ latest_version: cache.version } as api.UpdateInfo, settingsStore.editorSettings.ignoredUpdateVersion);
|
||||
if (ignored) await api.discardDownloadedUpdate(cache.cache_id);
|
||||
else {
|
||||
setDownloaded(cache);
|
||||
updateInfo.value!.current_version = await api.getAppVersion();
|
||||
// An upgrade that happened outside this process leaves the previous
|
||||
// package in the cache. Adopting it would pin the dialog to "already
|
||||
// up to date" while still advertising a downloaded update (#13562).
|
||||
if (isPreparedUpdateObsolete(cache.version, updateInfo.value!.current_version, cache.channel ?? "stable")) {
|
||||
await discardSupersededUpdate(cache);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -545,6 +581,10 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
|
||||
})
|
||||
.catch(fail);
|
||||
});
|
||||
const stopChannelWatch = watch(updateChannel, (channel, previous) => {
|
||||
if (disposed || channel === previous || isIgnoringUpdate.value) return;
|
||||
void changeUpdateChannel(channel).catch(fail);
|
||||
});
|
||||
// Hiding the toolbar entry mid-session must also stop background activity,
|
||||
// and re-showing it resumes the silent checks.
|
||||
const stopUpdateEntryWatch = watch(updateEntryVisible, (visible) => {
|
||||
@@ -566,6 +606,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
|
||||
clearRetry();
|
||||
clearInterval(hourlyTimer);
|
||||
stopSettingsWatch();
|
||||
stopChannelWatch();
|
||||
stopUpdateEntryWatch();
|
||||
updatePreparationRelease?.();
|
||||
updatePreparationRelease = undefined;
|
||||
@@ -596,6 +637,7 @@ export function useAppUpdater(options: UseAppUpdaterOptions = {}) {
|
||||
formatUpdateError,
|
||||
openLatestRelease,
|
||||
changeUpdateDownloadSource,
|
||||
changeUpdateChannel,
|
||||
ignoreCurrentVersion,
|
||||
downloadUpdateInBackground,
|
||||
cancelDownload,
|
||||
|
||||
@@ -442,6 +442,8 @@ export default withEnglishFallback({
|
||||
retryDownload: "Endirməni yenidən sınayın",
|
||||
centerTitle: "Yeniləmə mərkəzi",
|
||||
centerAvailable: "{count} yeniləmə mövcuddur",
|
||||
betaChannelBadge: "Dev Beta kanalı",
|
||||
stableDowngradeBlocked: "Stabil kanal cari beta quruluşunuzdan köhnədir. Kanalı dəyişmək avtomatik endirmə etməyəcək; versiya tarixçəsindən stabil quraşdırıcını seçin.",
|
||||
check: "Yeniləmələri yoxla",
|
||||
checking: "Yeniləmələr yoxlanılır…",
|
||||
restartRequiredTooltip: "Yeniləmə quraşdırılıb — tamamlamaq üçün yenidən başladın",
|
||||
@@ -7863,6 +7865,10 @@ export default withEnglishFallback({
|
||||
updateDownloadSourceDescription: "Tətbiq yeniləmələrinin, verilənlər bazası agentlərinin, drayverlərin və idarə olunan JRE-lərin haradan endiriləcəyini seçin. Rəsmi mənbə tövsiyə olunur; CNB materik Çin şəbəkələrində daha sürətli ola bilər.",
|
||||
updateDownloadSourceOfficial: "Rəsmi mənbə (tövsiyə olunur)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
updateChannel: "Yeniləmə kanalı",
|
||||
updateChannelDescription: "Stabil versiya tövsiyə olunur. Beta daha yeni quruluşları tez alır, lakin daha qeyri-sabit ola bilər.",
|
||||
updateChannelStable: "Stabil (tövsiyə olunur)",
|
||||
updateChannelBeta: "Dev Beta",
|
||||
debugLoggingEnabled: "Sazlama jurnallarını aktiv et",
|
||||
debugLoggingEnabledDescription: "Bildirilən problemləri araşdırarkən istifadəçi tərəfində daha ətraflı yerli diaqnostika jurnalları qeydə alın. Standart olaraq deaktivdir.",
|
||||
metadataCacheMemoryLimit: "Metaverilənlərin yaddaş keşinin həddi",
|
||||
|
||||
@@ -1325,6 +1325,8 @@ export default {
|
||||
retryDownload: "Retry Download",
|
||||
centerTitle: "Update Center",
|
||||
centerAvailable: "{count} available",
|
||||
betaChannelBadge: "Dev Beta channel",
|
||||
stableDowngradeBlocked: "The stable channel is older than your current beta build. Switching channels will not downgrade automatically; use the version history to choose a stable installer.",
|
||||
check: "Check for updates",
|
||||
checking: "Checking for updates…",
|
||||
restartRequiredTooltip: "Update installed — restart to finish",
|
||||
@@ -10392,6 +10394,10 @@ export default {
|
||||
updateDownloadSourceDescription: "Choose where app updates, database agents, drivers, and managed JREs are downloaded from. The official source is recommended; CNB can be faster on mainland China networks.",
|
||||
updateDownloadSourceOfficial: "Official source (recommended)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
updateChannel: "Update channel",
|
||||
updateChannelDescription: "Stable is recommended. Beta receives newer builds earlier and may be less stable.",
|
||||
updateChannelStable: "Stable (recommended)",
|
||||
updateChannelBeta: "Dev Beta",
|
||||
debugLoggingEnabled: "Enable debug logs",
|
||||
debugLoggingEnabledDescription: "Record more detailed local user-side diagnostic logs while troubleshooting reports. Disabled by default.",
|
||||
metadataCacheMemoryLimit: "Metadata memory cache limit",
|
||||
|
||||
@@ -1122,6 +1122,8 @@ export default withEnglishFallback({
|
||||
retryDownload: "Reintentar descarga",
|
||||
centerTitle: "Centro de actualizaciones",
|
||||
centerAvailable: "{count} disponibles",
|
||||
betaChannelBadge: "Canal Dev Beta",
|
||||
stableDowngradeBlocked: "El canal estable es anterior a tu versión beta actual. Cambiar de canal no hará una degradación automática; elige un instalador estable en el historial de versiones.",
|
||||
check: "Buscar actualizaciones",
|
||||
checking: "Buscando actualizaciones…",
|
||||
restartRequiredTooltip: "Actualización instalada, reinicia para finalizar",
|
||||
@@ -9187,6 +9189,10 @@ export default withEnglishFallback({
|
||||
updateDownloadSourceDescription: "Elige de dónde se descargan los instaladores de actualización. Se recomienda la fuente oficial; CNB puede ser más rápido en redes de China continental.",
|
||||
updateDownloadSourceOfficial: "Fuente oficial (recomendada)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
updateChannel: "Canal de actualización",
|
||||
updateChannelDescription: "Se recomienda Estable. Beta recibe versiones nuevas antes, pero puede ser menos estable.",
|
||||
updateChannelStable: "Estable (recomendado)",
|
||||
updateChannelBeta: "Dev Beta",
|
||||
debugLoggingEnabled: "Activar logs de depuración",
|
||||
debugLoggingEnabledDescription: "Registra logs de diagnóstico locales del lado del usuario al investigar reportes. Desactivado por defecto.",
|
||||
metadataCacheMemoryLimit: "Límite de memoria de la caché de metadatos",
|
||||
|
||||
@@ -629,6 +629,8 @@ export default withEnglishFallback({
|
||||
retryDownload: "Coba Unduh Lagi",
|
||||
centerTitle: "Update Center",
|
||||
centerAvailable: "{count} tersedia",
|
||||
betaChannelBadge: "Saluran Dev Beta",
|
||||
stableDowngradeBlocked: "Saluran stabil lebih lama daripada build beta saat ini. Mengganti saluran tidak akan menurunkan versi otomatis; pilih penginstal stabil dari riwayat versi.",
|
||||
check: "Periksa pembaruan",
|
||||
checking: "Memeriksa pembaruan…",
|
||||
restartRequiredTooltip: "Pembaruan terpasang — restart untuk menyelesaikan",
|
||||
@@ -8603,6 +8605,10 @@ export default withEnglishFallback({
|
||||
updateDownloadSourceDescription: "Pilih tempat pembaruan aplikasi, agen database, driver, dan JRE terkelola diunduh. Sumber resmi direkomendasikan; CNB bisa lebih cepat untuk jaringan di Tiongkok daratan.",
|
||||
updateDownloadSourceOfficial: "Sumber resmi (direkomendasikan)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
updateChannel: "Saluran pembaruan",
|
||||
updateChannelDescription: "Stabil direkomendasikan. Beta menerima build baru lebih awal dan mungkin kurang stabil.",
|
||||
updateChannelStable: "Stabil (disarankan)",
|
||||
updateChannelBeta: "Dev Beta",
|
||||
debugLoggingEnabled: "Aktifkan log debug",
|
||||
debugLoggingEnabledDescription: "Mencatat log diagnostik sisi pengguna lokal yang lebih terperinci saat menangani laporan. Dinonaktifkan secara default.",
|
||||
metadataCacheMemoryLimit: "Batas cache memori metadata",
|
||||
|
||||
@@ -1110,6 +1110,8 @@ export default withEnglishFallback({
|
||||
retryDownload: "Riprova download",
|
||||
centerTitle: "Centro aggiornamenti",
|
||||
centerAvailable: "{count} disponibili",
|
||||
betaChannelBadge: "Canale Dev Beta",
|
||||
stableDowngradeBlocked: "Il canale stabile è precedente alla build beta attuale. Il cambio di canale non esegue il downgrade automatico; scegli un installer stabile dalla cronologia versioni.",
|
||||
check: "Verifica aggiornamenti",
|
||||
checking: "Verifica degli aggiornamenti in corso…",
|
||||
restartRequiredTooltip: "Aggiornamento installato, riavvia per completare",
|
||||
@@ -8966,6 +8968,10 @@ export default withEnglishFallback({
|
||||
updateDownloadSourceDescription: "Scegli da dove scaricare gli installer degli aggiornamenti. La fonte ufficiale è consigliata; CNB può essere più veloce sulle reti della Cina continentale.",
|
||||
updateDownloadSourceOfficial: "Fonte ufficiale (consigliata)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
updateChannel: "Canale di aggiornamento",
|
||||
updateChannelDescription: "Stabile è consigliato. Beta riceve prima le nuove build, ma può essere meno stabile.",
|
||||
updateChannelStable: "Stabile (consigliato)",
|
||||
updateChannelBeta: "Dev Beta",
|
||||
debugLoggingEnabled: "Abilita log di debug",
|
||||
debugLoggingEnabledDescription: "Registra log diagnostici locali lato utente durante l'analisi delle segnalazioni. Disattivato per impostazione predefinita.",
|
||||
metadataCacheMemoryLimit: "Limite di memoria della cache dei metadati",
|
||||
|
||||
@@ -1111,6 +1111,8 @@ export default withEnglishFallback({
|
||||
retryDownload: "ダウンロードを再試行",
|
||||
centerTitle: "アップデートセンター",
|
||||
centerAvailable: "{count} 件更新可能",
|
||||
betaChannelBadge: "Dev Beta チャンネル",
|
||||
stableDowngradeBlocked: "安定版チャンネルは現在の Beta ビルドより古い状態です。チャンネルを切り替えても自動でダウングレードされません。バージョン履歴から安定版インストーラーを選択してください。",
|
||||
check: "アップデートを確認",
|
||||
checking: "アップデートを確認中…",
|
||||
restartRequiredTooltip: "アップデートがインストールされました。再起動して完了してください",
|
||||
@@ -10340,6 +10342,10 @@ export default withEnglishFallback({
|
||||
updateDownloadSourceDescription: "アプリ内アップデートのインストーラーのダウンロード元を選択します。公式ソース推奨。中国本土ではCNBの方が高速な場合があります。",
|
||||
updateDownloadSourceOfficial: "公式ソース(推奨)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
updateChannel: "更新チャンネル",
|
||||
updateChannelDescription: "安定版を推奨します。Beta は新しいビルドを早く受け取れますが、不安定な場合があります。",
|
||||
updateChannelStable: "安定版(推奨)",
|
||||
updateChannelBeta: "Dev Beta",
|
||||
infiniteScroll: "無限スクロール読み込み",
|
||||
autoCalculateTotalRows: "総行数を自動計算",
|
||||
autoCalculateTotalRowsDescription: "クエリごとに COUNT(*) を自動実行し、一致する総行数を表示します。大きなクエリを高速に保つため既定はオフです。結果フッターから必要に応じて計算できます。",
|
||||
|
||||
@@ -1117,6 +1117,8 @@ export default withEnglishFallback({
|
||||
retryDownload: "다운로드 재시도",
|
||||
centerTitle: "업데이트 센터",
|
||||
centerAvailable: "{count}개 사용 가능",
|
||||
betaChannelBadge: "Dev Beta 채널",
|
||||
stableDowngradeBlocked: "안정 채널이 현재 Beta 빌드보다 이전 버전입니다. 채널을 바꿔도 자동으로 다운그레이드되지 않으므로 버전 기록에서 안정 버전 설치 파일을 선택하세요.",
|
||||
check: "업데이트 확인",
|
||||
checking: "업데이트 확인 중…",
|
||||
restartRequiredTooltip: "업데이트 설치 완료 — 다시 시작하여 완료하세요",
|
||||
@@ -9089,6 +9091,10 @@ export default withEnglishFallback({
|
||||
updateDownloadSourceDescription: "앱 업데이트, 데이터베이스 에이전트, 드라이버, 관리 JRE를 다운로드할 위치를 선택하세요. 공식 소스를 권장합니다. 중국 본토 네트워크에서는 CNB가 더 빠를 수 있습니다.",
|
||||
updateDownloadSourceOfficial: "공식 소스 (권장)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
updateChannel: "업데이트 채널",
|
||||
updateChannelDescription: "안정 버전을 권장합니다. Beta는 새 빌드를 먼저 받지만 불안정할 수 있습니다.",
|
||||
updateChannelStable: "안정 버전(권장)",
|
||||
updateChannelBeta: "Dev Beta",
|
||||
debugLoggingEnabled: "디버그 로그 활성화",
|
||||
debugLoggingEnabledDescription: "문제 해결 보고서를 작성하는 동안 더 자세한 로컬 사용자 진단 로그를 기록합니다. 기본적으로 비활성화됩니다.",
|
||||
duckDbWorkerProcessIsolation: "DuckDB 프로세스 격리 모드 (실험적)",
|
||||
|
||||
@@ -689,6 +689,8 @@ export default withEnglishFallback({
|
||||
retryDownload: "Ponów pobieranie",
|
||||
centerTitle: "Centrum aktualizacji",
|
||||
centerAvailable: "Dostępne: {count}",
|
||||
betaChannelBadge: "Kanał Dev Beta",
|
||||
stableDowngradeBlocked: "Kanał stabilny jest starszy niż bieżąca kompilacja beta. Zmiana kanału nie obniży wersji automatycznie; wybierz stabilny instalator w historii wersji.",
|
||||
check: "Sprawdź aktualizacje",
|
||||
checking: "Sprawdzanie aktualizacji…",
|
||||
restartRequiredTooltip: "Zainstalowano aktualizację — uruchom ponownie, aby zakończyć",
|
||||
@@ -9029,6 +9031,10 @@ export default withEnglishFallback({
|
||||
updateDownloadSourceDescription: "Wybierz, skąd pobierane są aktualizacje aplikacji, agenci baz danych, sterowniki i zarządzane środowiska JRE. Zalecane jest oficjalne źródło; CNB może być szybsze w sieciach w Chinach kontynentalnych.",
|
||||
updateDownloadSourceOfficial: "Oficjalne źródło (zalecane)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
updateChannel: "Kanał aktualizacji",
|
||||
updateChannelDescription: "Zalecany jest kanał stabilny. Beta otrzymuje nowsze kompilacje wcześniej, ale może być mniej stabilna.",
|
||||
updateChannelStable: "Stabilny (zalecany)",
|
||||
updateChannelBeta: "Dev Beta",
|
||||
debugLoggingEnabled: "Włącz dziennik debugowania (Debug Logs)",
|
||||
debugLoggingEnabledDescription: "Zapisuj bardziej szczegółowe lokalne dzienniki diagnostyczne podczas rozwiązywania problemów. Domyślnie wyłączone.",
|
||||
metadataCacheMemoryLimit: "Limit pamięci podręcznej metadanych",
|
||||
|
||||
@@ -1111,6 +1111,8 @@ export default withEnglishFallback({
|
||||
retryDownload: "Tentar baixar novamente",
|
||||
centerTitle: "Central de atualizações",
|
||||
centerAvailable: "{count} disponíveis",
|
||||
betaChannelBadge: "Canal Dev Beta",
|
||||
stableDowngradeBlocked: "O canal estável é anterior ao build beta atual. Trocar de canal não fará downgrade automático; escolha um instalador estável no histórico de versões.",
|
||||
check: "Verificar atualizações",
|
||||
checking: "Verificando atualizações…",
|
||||
restartRequiredTooltip: "Atualização instalada, reinicie para concluir",
|
||||
@@ -8963,6 +8965,10 @@ export default withEnglishFallback({
|
||||
updateDownloadSourceDescription: "Escolha de onde os instaladores de atualização no aplicativo são baixados. A fonte oficial é recomendada; CNB pode ser mais rápido em redes da China continental.",
|
||||
updateDownloadSourceOfficial: "Fonte oficial (recomendada)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
updateChannel: "Canal de atualização",
|
||||
updateChannelDescription: "Estável é recomendado. Beta recebe builds novos antes, mas pode ser menos estável.",
|
||||
updateChannelStable: "Estável (recomendado)",
|
||||
updateChannelBeta: "Dev Beta",
|
||||
debugLoggingEnabled: "Ativar logs de depuração",
|
||||
debugLoggingEnabledDescription: "Registra logs locais de diagnóstico do lado do usuário ao investigar relatos. Desativado por padrão.",
|
||||
metadataCacheMemoryLimit: "Limite de memória do cache de metadados",
|
||||
|
||||
@@ -1084,6 +1084,8 @@ export default withEnglishFallback({
|
||||
retryDownload: "Повторить загрузку",
|
||||
centerTitle: "Центр обновлений",
|
||||
centerAvailable: "Доступно: {count}",
|
||||
betaChannelBadge: "Канал Dev Beta",
|
||||
stableDowngradeBlocked: "Стабильный канал старше текущей beta-сборки. Переключение канала не выполнит автоматический откат; выберите стабильный установщик в истории версий.",
|
||||
check: "Проверить обновления",
|
||||
checking: "Проверка обновлений…",
|
||||
restartRequiredTooltip: "Обновление установлено — перезапустите для завершения",
|
||||
@@ -10549,6 +10551,10 @@ export default withEnglishFallback({
|
||||
updateDownloadSourceDescription: "Выберите, откуда скачиваются обновления приложения, агенты баз данных, драйверы и управляемые JRE. Рекомендуется официальный источник; CNB может быть быстрее в сетях материкового Китая.",
|
||||
updateDownloadSourceOfficial: "Официальный источник (рекомендуется)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
updateChannel: "Канал обновлений",
|
||||
updateChannelDescription: "Рекомендуется стабильный канал. Beta получает новые сборки раньше, но может быть менее стабильной.",
|
||||
updateChannelStable: "Стабильный (рекомендуется)",
|
||||
updateChannelBeta: "Dev Beta",
|
||||
debugLoggingEnabled: "Включить отладочные журналы",
|
||||
debugLoggingEnabledDescription: "Записывать более подробные локальные диагностические журналы на стороне пользователя при устранении неполадок. По умолчанию отключено.",
|
||||
metadataCacheMemoryLimit: "Лимит кэша метаданных в памяти",
|
||||
|
||||
@@ -1184,6 +1184,8 @@ export default withEnglishFallback({
|
||||
retryDownload: "ลองดาวน์โหลดอีกครั้ง",
|
||||
centerTitle: "ศูนย์อัปเดต",
|
||||
centerAvailable: "พร้อมใช้งาน {count} รายการ",
|
||||
betaChannelBadge: "ช่อง Dev Beta",
|
||||
stableDowngradeBlocked: "ช่องเสถียรเก่ากว่าบิลด์ Beta ปัจจุบัน การเปลี่ยนช่องจะไม่ดาวน์เกรดอัตโนมัติ โปรดเลือกตัวติดตั้งรุ่นเสถียรจากประวัติเวอร์ชัน",
|
||||
check: "ตรวจสอบอัปเดต",
|
||||
checking: "กำลังตรวจสอบอัปเดต…",
|
||||
restartRequiredTooltip: "ติดตั้งอัปเดตแล้ว — รีสตาร์ตเพื่อเสร็จสิ้น",
|
||||
@@ -10926,6 +10928,10 @@ export default withEnglishFallback({
|
||||
updateDownloadSourceDescription: "เลือกแหล่งดาวน์โหลดการอัปเดตแอป Agent ฐานข้อมูล Driver และ JRE ที่จัดการอยู่ แนะนำแหล่งทางการ; CNB อาจเร็วกว่าในเครือข่ายจีนแผ่นดินใหญ่",
|
||||
updateDownloadSourceOfficial: "แหล่งทางการ (แนะนำ)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
updateChannel: "ช่องทางการอัปเดต",
|
||||
updateChannelDescription: "แนะนำให้ใช้รุ่นเสถียร Beta จะได้รับบิลด์ใหม่ก่อน แต่อาจไม่เสถียร",
|
||||
updateChannelStable: "รุ่นเสถียร (แนะนำ)",
|
||||
updateChannelBeta: "Dev Beta",
|
||||
debugLoggingEnabled: "เปิดใช้ Debug Log",
|
||||
debugLoggingEnabledDescription: "บันทึก Diagnostic Log ฝั่งผู้ใช้แบบละเอียดขึ้นระหว่างแก้ไขปัญหารายงาน ปิดไว้โดยค่าเริ่มต้น",
|
||||
metadataCacheMemoryLimit: "ขีดจำกัด Cache หน่วยความจำ Metadata",
|
||||
|
||||
@@ -442,6 +442,8 @@ export default withEnglishFallback({
|
||||
retryDownload: "İndirmeyi yeniden dene",
|
||||
centerTitle: "Güncelleme Merkezi",
|
||||
centerAvailable: "{count} güncelleme mevcut",
|
||||
betaChannelBadge: "Dev Beta kanalı",
|
||||
stableDowngradeBlocked: "Kararlı kanal mevcut beta derlemenizden daha eski. Kanalı değiştirmek otomatik düşürme yapmaz; sürüm geçmişinden kararlı yükleyiciyi seçin.",
|
||||
check: "Güncellemeleri denetle",
|
||||
checking: "Güncellemeler denetleniyor…",
|
||||
restartRequiredTooltip: "Güncelleme kuruldu — bitirmek için yeniden başlatın",
|
||||
@@ -7744,6 +7746,10 @@ export default withEnglishFallback({
|
||||
updateDownloadSourceDescription: "Uygulama güncellemelerinin, veritabanı agent'larının, sürücülerin ve yönetilen JRE'lerin nereden indirileceğini seçin. Resmî kaynak önerilir; Çin anakarası ağlarında CNB daha hızlı olabilir.",
|
||||
updateDownloadSourceOfficial: "Resmî kaynak (önerilen)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
updateChannel: "Güncelleme kanalı",
|
||||
updateChannelDescription: "Kararlı kanal önerilir. Beta yeni derlemeleri daha erken alır ancak daha az kararlı olabilir.",
|
||||
updateChannelStable: "Kararlı (önerilen)",
|
||||
updateChannelBeta: "Dev Beta",
|
||||
debugLoggingEnabled: "Hata ayıklama günlüklerini etkinleştir",
|
||||
debugLoggingEnabledDescription: "Sorun bildirimlerini incelerken daha ayrıntılı yerel kullanıcı tarafı tanılama günlükleri kaydeder. Varsayılan olarak kapalıdır.",
|
||||
metadataCacheMemoryLimit: "Meta veri bellek önbelleği sınırı",
|
||||
|
||||
@@ -1261,6 +1261,8 @@ export default withEnglishFallback({
|
||||
retryDownload: "重试下载",
|
||||
centerTitle: "更新中心",
|
||||
centerAvailable: "{count} 项可用更新",
|
||||
betaChannelBadge: "Dev Beta 渠道",
|
||||
stableDowngradeBlocked: "稳定版渠道低于当前 Beta 版本,切换渠道不会自动降级;请在版本历史中选择稳定版安装包。",
|
||||
check: "检查更新",
|
||||
checking: "正在检查更新…",
|
||||
restartRequiredTooltip: "更新已安装,重启以完成更新",
|
||||
@@ -10358,6 +10360,10 @@ export default withEnglishFallback({
|
||||
updateDownloadSourceDescription: "选择应用更新、数据库 Agent、驱动和托管 JRE 的下载来源。官方源为推荐选项,CNB 适合国内网络环境。",
|
||||
updateDownloadSourceOfficial: "官方源(推荐)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
updateChannel: "更新渠道",
|
||||
updateChannelDescription: "稳定版适合日常使用;Dev Beta 会更早收到新版本,但可能存在不稳定问题。",
|
||||
updateChannelStable: "稳定版(推荐)",
|
||||
updateChannelBeta: "Dev Beta",
|
||||
debugLoggingEnabled: "启用调试日志",
|
||||
debugLoggingEnabledDescription: "开启后在本机记录更详细的用户侧诊断日志,反馈问题时可临时开启;默认关闭。",
|
||||
metadataCacheMemoryLimit: "元数据内存缓存上限",
|
||||
|
||||
@@ -1096,6 +1096,8 @@ export default withEnglishFallback({
|
||||
retryDownload: "重試下載",
|
||||
centerTitle: "更新中心",
|
||||
centerAvailable: "{count} 項可用更新",
|
||||
betaChannelBadge: "Dev Beta 頻道",
|
||||
stableDowngradeBlocked: "穩定版頻道低於目前 Beta 版本,切換頻道不會自動降級;請在版本歷史中選擇穩定版安裝套件。",
|
||||
check: "檢查更新",
|
||||
checking: "正在檢查更新…",
|
||||
restartRequiredTooltip: "更新已安裝,重新啟動以完成更新",
|
||||
@@ -9513,6 +9515,10 @@ export default withEnglishFallback({
|
||||
updateDownloadSourceDescription: "選擇應用程式內更新安裝程式的下載來源。建議使用官方來源;CNB 在中國大陸網路環境下可能更快。",
|
||||
updateDownloadSourceOfficial: "官方來源(建議)",
|
||||
updateDownloadSourceCnb: "CNB",
|
||||
updateChannel: "更新頻道",
|
||||
updateChannelDescription: "穩定版適合日常使用;Dev Beta 會更早收到新版本,但可能存在不穩定問題。",
|
||||
updateChannelStable: "穩定版(推薦)",
|
||||
updateChannelBeta: "Dev Beta",
|
||||
exportSection: "匯出",
|
||||
csvQuoteMode: "CSV 欄位引號",
|
||||
csvQuoteModeDescription: "選擇 CSV 匯出時欄位使用雙引號的方式。",
|
||||
|
||||
@@ -144,15 +144,19 @@ describe("Windows 7 fixed WebView2 runtime bundle", () => {
|
||||
const releaseWin7Job = releaseWorkflow.slice(releaseWorkflow.indexOf(" build-windows-7-offline:"), releaseWorkflow.indexOf(" static-browser:"));
|
||||
const ciWin7Job = ciWorkflow.slice(ciWorkflow.indexOf(" windows-win7-bundle:"), ciWorkflow.indexOf(" duckdb-windows-driver:"));
|
||||
|
||||
for (const job of [releaseWin7Job, ciWin7Job]) {
|
||||
expect(job).toContain("RUSTC_WRAPPER: sccache");
|
||||
expect(job).toContain("SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}");
|
||||
expect(job).toContain("SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1");
|
||||
expect(job).toContain('SCCACHE_IDLE_TIMEOUT: "0"');
|
||||
expect(job).toContain("mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba");
|
||||
expect(job).toContain('version: "v0.16.0"');
|
||||
expect(job).not.toMatch(/^\s+(?:CC|CXX):/m);
|
||||
expect(releaseWin7Job).toContain("swatinem/rust-cache@v2");
|
||||
expect(releaseWin7Job).not.toMatch(/RUSTC_WRAPPER|SCCACHE_|sccache/i);
|
||||
for (const line of [
|
||||
"RUSTC_WRAPPER: sccache",
|
||||
"SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}",
|
||||
"SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1",
|
||||
'SCCACHE_IDLE_TIMEOUT: "0"',
|
||||
"mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba",
|
||||
'version: "v0.16.0"',
|
||||
]) {
|
||||
expect(ciWin7Job).toContain(line);
|
||||
}
|
||||
expect(ciWin7Job).not.toMatch(/^\s+(?:CC|CXX):/m);
|
||||
expect(releaseWin7Job).not.toContain("CARGO_PROFILE_RELEASE_");
|
||||
expect(ciWin7Job).toContain('CARGO_PROFILE_RELEASE_LTO: "off"');
|
||||
expect(ciWin7Job).toContain('CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"');
|
||||
|
||||
@@ -174,6 +174,7 @@ import type {
|
||||
UpdateInfo,
|
||||
DownloadedUpdate,
|
||||
UpdateDownloadSource,
|
||||
UpdateChannel,
|
||||
RedisCollectionPage,
|
||||
RedisBlob,
|
||||
RedisDatabaseInfo,
|
||||
@@ -5837,10 +5838,11 @@ export async function deleteHistoryEntry(id: string): Promise<void> {
|
||||
// Updates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function checkForUpdates(locale?: string, source?: UpdateDownloadSource): Promise<UpdateInfo> {
|
||||
export async function checkForUpdates(locale?: string, source?: UpdateDownloadSource, channel: UpdateChannel = "stable"): Promise<UpdateInfo> {
|
||||
const params = new URLSearchParams();
|
||||
if (locale) params.set("locale", locale);
|
||||
if (source) params.set("source", source);
|
||||
params.set("channel", channel);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return get(`/api/update/check${query}`);
|
||||
}
|
||||
@@ -5910,7 +5912,7 @@ export async function getSystemProxyUrl(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function downloadUpdate(_source: UpdateDownloadSource, _latestVersion: string, _attemptId: string, _releaseNotes?: string): Promise<DownloadedUpdate> {
|
||||
export async function downloadUpdate(_source: UpdateDownloadSource, _latestVersion: string, _attemptId: string, _releaseNotes?: string, _channel: UpdateChannel = "stable", _releaseTag?: string): Promise<DownloadedUpdate> {
|
||||
throw new Error("In-app update downloads are only available in the desktop app.");
|
||||
}
|
||||
|
||||
|
||||
@@ -3640,9 +3640,13 @@ export interface UpdateInfo {
|
||||
release_name: string;
|
||||
release_url: string;
|
||||
release_notes: string;
|
||||
channel?: "stable" | "beta";
|
||||
release_tag?: string;
|
||||
is_promoted?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateDownloadSource = "official" | "cnb";
|
||||
export type UpdateChannel = "stable" | "beta";
|
||||
|
||||
export interface DownloadedUpdate {
|
||||
cache_id: string;
|
||||
@@ -3651,6 +3655,9 @@ export interface DownloadedUpdate {
|
||||
release_url: string;
|
||||
release_notes: string;
|
||||
downloaded_at: number;
|
||||
channel?: UpdateChannel;
|
||||
release_tag?: string;
|
||||
is_promoted?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateDownloadProgress {
|
||||
@@ -3710,8 +3717,8 @@ export async function uninstallNpmMcpServer(): Promise<string> {
|
||||
return invoke("uninstall_npm_mcp_server");
|
||||
}
|
||||
|
||||
export async function checkForUpdates(locale?: string, source?: UpdateDownloadSource): Promise<UpdateInfo> {
|
||||
return invoke("check_for_updates", { locale, source });
|
||||
export async function checkForUpdates(locale?: string, source?: UpdateDownloadSource, channel: UpdateChannel = "stable"): Promise<UpdateInfo> {
|
||||
return invoke("check_for_updates", { locale, source, channel });
|
||||
}
|
||||
|
||||
export async function fetchChangelog(lang?: string): Promise<import("@/lib/app/changelog").ChangelogData> {
|
||||
@@ -3730,8 +3737,8 @@ export async function getSystemProxyUrl(): Promise<string | null> {
|
||||
return invoke("get_system_proxy_url");
|
||||
}
|
||||
|
||||
export async function downloadUpdate(source: UpdateDownloadSource, latestVersion: string, attemptId: string, releaseNotes?: string): Promise<DownloadedUpdate> {
|
||||
return invoke("download_update", { source, latestVersion, attemptId, releaseNotes });
|
||||
export async function downloadUpdate(source: UpdateDownloadSource, latestVersion: string, attemptId: string, releaseNotes?: string, channel: UpdateChannel = "stable", releaseTag?: string): Promise<DownloadedUpdate> {
|
||||
return invoke("download_update", { source, channel, latestVersion, attemptId, releaseNotes, releaseTag });
|
||||
}
|
||||
|
||||
export async function cancelUpdateDownload(): Promise<void> {
|
||||
|
||||
@@ -171,6 +171,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
|
||||
"globalDateTimeExportFormat",
|
||||
"globalDateTimeImportFormat",
|
||||
"updateDownloadSource",
|
||||
"updateChannel",
|
||||
"toolbarItems",
|
||||
"snippets",
|
||||
"sqlShortcuts",
|
||||
|
||||
@@ -728,6 +728,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
|
||||
{ id: "updates-jdbc", category: "updates", titleKey: "settings.autoUpdateJdbc", descriptionKey: "settings.autoUpdateJdbcDescription", targetId: "updates" },
|
||||
{ id: "updates-mcp", category: "updates", titleKey: "settings.autoUpdateMcp", descriptionKey: "settings.autoUpdateMcpDescription", targetId: "updates" },
|
||||
{ id: "updates-plugins", category: "updates", titleKey: "settings.autoUpdatePlugins", descriptionKey: "settings.autoUpdatePluginsDescription", targetId: "updates" },
|
||||
{ id: "updates-channel", category: "updates", titleKey: "settings.updateChannel", descriptionKey: "settings.updateChannelDescription", targetId: "updates", keywordKeys: ["settings.updateChannelStable", "settings.updateChannelBeta"] },
|
||||
{ id: "updates-source", category: "updates", titleKey: "settings.updateDownloadSource", descriptionKey: "settings.updateDownloadSourceDescription", targetId: "updates", keywordKeys: ["settings.updateDownloadSourceOfficial", "settings.updateDownloadSourceCnb"] },
|
||||
// Fixed controls added alongside the full template coverage audit.
|
||||
{ id: "editor-keep-explicit-transaction-in-auto-commit", category: "editor", titleKey: "settings.keepExplicitTransactionInAutoCommit", descriptionKey: "settings.keepExplicitTransactionInAutoCommitDescription", targetId: "editor" },
|
||||
|
||||
@@ -291,7 +291,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readon
|
||||
],
|
||||
shortcuts: ["shortcuts", "sqlShortcuts"],
|
||||
snippets: ["snippets", "snippetTriggerKey"],
|
||||
other: ["updateDownloadSource", "updateNotificationsEnabled", "autoDownloadUpdates", "autoUpdateApp", "autoUpdateDrivers", "autoUpdateJdbc", "autoUpdateMcp", "autoUpdatePlugins"],
|
||||
other: ["updateDownloadSource", "updateChannel", "updateNotificationsEnabled", "autoDownloadUpdates", "autoUpdateApp", "autoUpdateDrivers", "autoUpdateJdbc", "autoUpdateMcp", "autoUpdatePlugins"],
|
||||
};
|
||||
|
||||
const KEY_TO_CATEGORY = new Map<string, SettingsTransferCategoryId>();
|
||||
|
||||
@@ -2368,6 +2368,7 @@ describe("settingsStore editor settings persistence", () => {
|
||||
it("rolls back a failed atomic update and allows retry", async () => {
|
||||
const loadEditorSettings = vi.fn().mockResolvedValue({
|
||||
ignoredUpdateVersion: "",
|
||||
updateChannel: "stable",
|
||||
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
|
||||
sidebarBrowseObjectsOnDatabaseActivationMigrationVersion: SIDEBAR_BROWSE_OBJECTS_MIGRATION_VERSION,
|
||||
welcomePageModeDefaultVersion: WELCOME_PAGE_DEFAULT_VERSION,
|
||||
@@ -2417,6 +2418,7 @@ describe("settingsStore editor settings persistence", () => {
|
||||
let resolveFirstSave!: () => void;
|
||||
const loadEditorSettings = vi.fn().mockResolvedValue({
|
||||
ignoredUpdateVersion: "",
|
||||
updateChannel: "stable",
|
||||
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
|
||||
sidebarBrowseObjectsOnDatabaseActivationMigrationVersion: SIDEBAR_BROWSE_OBJECTS_MIGRATION_VERSION,
|
||||
welcomePageModeDefaultVersion: WELCOME_PAGE_DEFAULT_VERSION,
|
||||
@@ -2454,6 +2456,7 @@ describe("settingsStore editor settings persistence", () => {
|
||||
let rejectFirstSave!: (error: Error) => void;
|
||||
const loadEditorSettings = vi.fn().mockResolvedValue({
|
||||
ignoredUpdateVersion: "",
|
||||
updateChannel: "stable",
|
||||
theme: "system",
|
||||
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
|
||||
sidebarBrowseObjectsOnDatabaseActivationMigrationVersion: SIDEBAR_BROWSE_OBJECTS_MIGRATION_VERSION,
|
||||
|
||||
@@ -159,6 +159,7 @@ export type InterfaceLayout = "separated" | "classic";
|
||||
export type WelcomePageMode = "intro" | "workspace";
|
||||
|
||||
export type UpdateDownloadSource = "official" | "cnb";
|
||||
export type UpdateChannel = "stable" | "beta";
|
||||
export type SqlSemanticDiagnosticsMode = "auto" | "enabled" | "disabled";
|
||||
export type OpenTabsRestoreMode = "all" | "pinned" | "none";
|
||||
export type AppCloseUnsavedTabsMode = "prompt" | "keep-drafts";
|
||||
@@ -1270,6 +1271,7 @@ export interface EditorSettings {
|
||||
exportRowLimit: number;
|
||||
queryExportKeysetOptimizationEnabled: boolean;
|
||||
updateDownloadSource: UpdateDownloadSource;
|
||||
updateChannel: UpdateChannel;
|
||||
ignoredUpdateVersion: string;
|
||||
pluginShortcuts: PluginShortcutSettings;
|
||||
/** Plugin ids the user granted `script-src 'unsafe-eval'` in the workbench sandbox. */
|
||||
@@ -1629,6 +1631,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
|
||||
exportRowLimit: 100000,
|
||||
queryExportKeysetOptimizationEnabled: true,
|
||||
updateDownloadSource: "official",
|
||||
updateChannel: "stable",
|
||||
ignoredUpdateVersion: "",
|
||||
pluginShortcuts: normalizePluginShortcutSettings(undefined),
|
||||
pluginGraphicsEngineIds: [],
|
||||
@@ -1818,6 +1821,10 @@ function normalizeUpdateDownloadSource(value: unknown): UpdateDownloadSource {
|
||||
return value === "cnb" ? "cnb" : DEFAULT_EDITOR_SETTINGS.updateDownloadSource;
|
||||
}
|
||||
|
||||
function normalizeUpdateChannel(value: unknown): UpdateChannel {
|
||||
return value === "beta" ? "beta" : "stable";
|
||||
}
|
||||
|
||||
function normalizeSqlSemanticDiagnosticsMode(value: unknown, legacyEnabled?: unknown): SqlSemanticDiagnosticsMode {
|
||||
if (value === "auto" || value === "enabled" || value === "disabled") return value;
|
||||
if (typeof legacyEnabled === "boolean") return legacyEnabled ? "enabled" : "disabled";
|
||||
@@ -2398,6 +2405,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
|
||||
exportRowLimit: typeof settings.exportRowLimit === "number" && settings.exportRowLimit >= 100 && settings.exportRowLimit <= 2147483647 ? Math.round(settings.exportRowLimit) : DEFAULT_EDITOR_SETTINGS.exportRowLimit,
|
||||
queryExportKeysetOptimizationEnabled: typeof settings.queryExportKeysetOptimizationEnabled === "boolean" ? settings.queryExportKeysetOptimizationEnabled : DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled,
|
||||
updateDownloadSource: normalizeUpdateDownloadSource(settings.updateDownloadSource),
|
||||
updateChannel: normalizeUpdateChannel(settings.updateChannel),
|
||||
ignoredUpdateVersion: typeof settings.ignoredUpdateVersion === "string" ? settings.ignoredUpdateVersion : DEFAULT_EDITOR_SETTINGS.ignoredUpdateVersion,
|
||||
pluginShortcuts: normalizePluginShortcutSettings(settings.pluginShortcuts),
|
||||
pluginGraphicsEngineIds: normalizePluginGraphicsEngineIds(settings.pluginGraphicsEngineIds),
|
||||
@@ -2644,7 +2652,17 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
const needsDataGridExtractorOptionsMigration =
|
||||
(typeof savedSettings.dataGridExtractorOptionsMigrationVersion !== "number" || savedSettings.dataGridExtractorOptionsMigrationVersion < DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION) && (savedNullText === "NULL" || savedSqlIncludeDatabaseName === false);
|
||||
const savedUpdateDownloadSource = (saved as { updateDownloadSource?: unknown }).updateDownloadSource;
|
||||
if (savedUpdateDownloadSource === "atomgit" || needsExecuteModeDefaultMigration || needsWelcomePageDefaultMigration || needsCompactTabTitleDefaultMigration || needsTabNavigationShortcutMigration || needsSidebarBrowseObjectsMigration || needsDataGridExtractorOptionsMigration) {
|
||||
const savedUpdateChannel = (saved as { updateChannel?: unknown }).updateChannel;
|
||||
if (
|
||||
savedUpdateDownloadSource === "atomgit" ||
|
||||
savedUpdateChannel !== normalizeUpdateChannel(savedUpdateChannel) ||
|
||||
needsExecuteModeDefaultMigration ||
|
||||
needsWelcomePageDefaultMigration ||
|
||||
needsCompactTabTitleDefaultMigration ||
|
||||
needsTabNavigationShortcutMigration ||
|
||||
needsSidebarBrowseObjectsMigration ||
|
||||
needsDataGridExtractorOptionsMigration
|
||||
) {
|
||||
// Persist one-time migrations so removed or unsafe defaults cannot reappear.
|
||||
await enqueueEditorSettingsSave().catch(() => {});
|
||||
}
|
||||
@@ -3309,6 +3327,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
if (partial.exportRowLimit !== undefined) editorSettings.value.exportRowLimit = Math.min(2147483647, Math.max(100, Math.round(partial.exportRowLimit)));
|
||||
if (partial.queryExportKeysetOptimizationEnabled !== undefined) editorSettings.value.queryExportKeysetOptimizationEnabled = partial.queryExportKeysetOptimizationEnabled;
|
||||
if (partial.updateDownloadSource !== undefined) editorSettings.value.updateDownloadSource = normalizeUpdateDownloadSource(partial.updateDownloadSource);
|
||||
if (partial.updateChannel !== undefined) editorSettings.value.updateChannel = normalizeUpdateChannel(partial.updateChannel);
|
||||
if (partial.ignoredUpdateVersion !== undefined) editorSettings.value.ignoredUpdateVersion = typeof partial.ignoredUpdateVersion === "string" ? partial.ignoredUpdateVersion : "";
|
||||
if (partial.pluginShortcuts !== undefined) editorSettings.value.pluginShortcuts = normalizePluginShortcutSettings(partial.pluginShortcuts);
|
||||
if (partial.pluginGraphicsEngineIds !== undefined) editorSettings.value.pluginGraphicsEngineIds = normalizePluginGraphicsEngineIds(partial.pluginGraphicsEngineIds);
|
||||
|
||||
@@ -4,15 +4,26 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
const LATEST_JSON_GITHUB_PATH: &str = "https://github.com/t8y2/dbx/releases/latest/download/latest.json";
|
||||
const LATEST_JSON_R2_PATH: &str = "releases/latest/latest.json";
|
||||
const LATEST_JSON_STABLE_R2_PATH: &str = "releases/stable/latest.json";
|
||||
const LATEST_JSON_BETA_R2_PATH: &str = "releases/beta/latest.json";
|
||||
const LATEST_JSON_CNB_PATH: &str = "https://cnb.cool/dbxio.com/dbx/-/releases/latest/download/latest.json";
|
||||
const LATEST_EN_NOTES_R2_PATH: &str = "changelog/latest-en.json";
|
||||
const GITHUB_RELEASE_API_PREFIX: &str = "https://api.github.com/repos/t8y2/dbx/releases/tags/v";
|
||||
const RELEASE_URL_PREFIX: &str = "https://github.com/t8y2/dbx/releases/tag/v";
|
||||
|
||||
fn default_promoted() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TauriRelease {
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub channel: UpdateChannel,
|
||||
#[serde(default)]
|
||||
pub release_tag: Option<String>,
|
||||
#[serde(default = "default_promoted")]
|
||||
pub is_promoted: bool,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
#[serde(default)]
|
||||
pub jdbc_plugin: Option<JdbcPluginLatest>,
|
||||
@@ -48,27 +59,54 @@ pub struct UpdateInfo {
|
||||
pub release_name: String,
|
||||
pub release_url: String,
|
||||
pub release_notes: String,
|
||||
pub channel: UpdateChannel,
|
||||
pub release_tag: String,
|
||||
pub is_promoted: bool,
|
||||
}
|
||||
|
||||
pub async fn fetch_latest_release(locale: &str, source: crate::DownloadSource) -> Result<TauriRelease, String> {
|
||||
fetch_latest_release_for_channel(locale, source, UpdateChannel::Stable).await
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UpdateChannel {
|
||||
#[default]
|
||||
Stable,
|
||||
Beta,
|
||||
}
|
||||
|
||||
pub async fn fetch_latest_release_for_channel(
|
||||
locale: &str,
|
||||
source: crate::DownloadSource,
|
||||
channel: UpdateChannel,
|
||||
) -> Result<TauriRelease, String> {
|
||||
let client = build_update_http_client()?;
|
||||
|
||||
let candidates = update_check_candidates(source);
|
||||
let candidates = update_check_candidates(source, channel);
|
||||
let resp = fetch_first_available(&client, &candidates).await?;
|
||||
|
||||
let mut release = resp.json::<TauriRelease>().await.map_err(|e| format!("Failed to parse update response: {e}"))?;
|
||||
if let Ok(github) = fetch_github_release_metadata(&client, &release.version).await {
|
||||
let identity_version = release_identity_version(&release).to_string();
|
||||
if let Ok(github) = fetch_github_release_metadata(&client, &identity_version).await {
|
||||
release.github = Some(github);
|
||||
}
|
||||
// 非中文界面用户额外拉取英文 release notes;失败/版本不匹配则保持 None,上层回退中文。
|
||||
if !is_chinese_locale(locale) {
|
||||
if let Ok(notes_en) = fetch_latest_release_notes_en(&client, &release.version).await {
|
||||
if let Ok(notes_en) = fetch_latest_release_notes_en(&client, &identity_version).await {
|
||||
release.notes_en = Some(notes_en);
|
||||
}
|
||||
}
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
/// A promoted beta publishes the stable base version in `version` but keeps its
|
||||
/// beta tag in `release_tag`; GitHub metadata and R2 release notes are both keyed
|
||||
/// by that tag.
|
||||
fn release_identity_version(release: &TauriRelease) -> &str {
|
||||
release.release_tag.as_deref().unwrap_or(&release.version)
|
||||
}
|
||||
|
||||
async fn fetch_first_available(client: &reqwest::Client, candidates: &[String]) -> Result<reqwest::Response, String> {
|
||||
let mut errors = Vec::with_capacity(candidates.len());
|
||||
for url in candidates {
|
||||
@@ -87,17 +125,36 @@ async fn fetch_first_available(client: &reqwest::Client, candidates: &[String])
|
||||
Err(format!("Failed to check updates: {}", errors.join("; ")))
|
||||
}
|
||||
|
||||
fn update_check_candidates(source: crate::DownloadSource) -> Vec<String> {
|
||||
fn update_check_candidates(source: crate::DownloadSource, channel: UpdateChannel) -> Vec<String> {
|
||||
let r2_path = match channel {
|
||||
UpdateChannel::Stable => LATEST_JSON_STABLE_R2_PATH,
|
||||
UpdateChannel::Beta => LATEST_JSON_BETA_R2_PATH,
|
||||
};
|
||||
match source {
|
||||
crate::DownloadSource::Official => {
|
||||
vec![format!("{}{LATEST_JSON_R2_PATH}", crate::R2_CDN_BASE), LATEST_JSON_GITHUB_PATH.to_string()]
|
||||
if matches!(channel, UpdateChannel::Beta) {
|
||||
vec![format!("{}{}", crate::R2_CDN_BASE, r2_path)]
|
||||
} else {
|
||||
vec![
|
||||
format!("{}{}", crate::R2_CDN_BASE, r2_path),
|
||||
format!("{}{LATEST_JSON_R2_PATH}", crate::R2_CDN_BASE),
|
||||
LATEST_JSON_GITHUB_PATH.to_string(),
|
||||
]
|
||||
}
|
||||
}
|
||||
// CNB exposes a moving latest release, so checking CNB does not need an official-source version first.
|
||||
crate::DownloadSource::Cnb => vec![
|
||||
LATEST_JSON_CNB_PATH.to_string(),
|
||||
format!("{}{LATEST_JSON_R2_PATH}", crate::R2_CDN_BASE),
|
||||
LATEST_JSON_GITHUB_PATH.to_string(),
|
||||
],
|
||||
crate::DownloadSource::Cnb => {
|
||||
if matches!(channel, UpdateChannel::Beta) {
|
||||
vec![format!("{}{}", crate::R2_CDN_BASE, r2_path)]
|
||||
} else {
|
||||
vec![
|
||||
format!("{}{}", crate::R2_CDN_BASE, r2_path),
|
||||
LATEST_JSON_CNB_PATH.to_string(),
|
||||
format!("{}{LATEST_JSON_R2_PATH}", crate::R2_CDN_BASE),
|
||||
LATEST_JSON_GITHUB_PATH.to_string(),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +220,7 @@ async fn fetch_github_release_metadata(
|
||||
|
||||
pub fn build_update_info(release: TauriRelease, current_version: &str) -> UpdateInfo {
|
||||
let latest_version = normalize_version(&release.version);
|
||||
let update_available = is_channel_update_available(release.channel, &latest_version, current_version);
|
||||
let github = release.github.as_ref();
|
||||
let release_notes = non_empty(release.notes_en.as_deref())
|
||||
.map(ToOwned::to_owned)
|
||||
@@ -173,13 +231,13 @@ pub fn build_update_info(release: TauriRelease, current_version: &str) -> Update
|
||||
.and_then(|metadata| non_empty(metadata.name.as_deref()))
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("DBX v{latest_version}"));
|
||||
let release_tag = release.release_tag.unwrap_or_else(|| format!("v{latest_version}"));
|
||||
let release_url = github
|
||||
.and_then(|metadata| non_empty(metadata.html_url.as_deref()))
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("{RELEASE_URL_PREFIX}{latest_version}"));
|
||||
|
||||
.unwrap_or_else(|| format!("https://github.com/t8y2/dbx/releases/tag/{release_tag}"));
|
||||
UpdateInfo {
|
||||
update_available: is_newer_version(&latest_version, current_version),
|
||||
update_available,
|
||||
portable_mode: false,
|
||||
manual_update_only: false,
|
||||
current_version: current_version.to_string(),
|
||||
@@ -187,9 +245,42 @@ pub fn build_update_info(release: TauriRelease, current_version: &str) -> Update
|
||||
release_url,
|
||||
release_notes,
|
||||
latest_version,
|
||||
channel: release.channel,
|
||||
release_tag,
|
||||
is_promoted: release.is_promoted,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_channel_update_available(channel: UpdateChannel, latest_version: &str, current_version: &str) -> bool {
|
||||
// A promoted beta keeps its prerelease version inside the installed binary,
|
||||
// while the stable manifest intentionally exposes the base version. Treat
|
||||
// that pair as the same release so switching back to stable never offers
|
||||
// the already-installed artifact again.
|
||||
if matches!(channel, UpdateChannel::Stable) {
|
||||
let latest = normalize_version(latest_version);
|
||||
let current = normalize_version(current_version);
|
||||
let latest_base = latest.split(['-', '+']).next().unwrap_or_default();
|
||||
let current_base = current.split(['-', '+']).next().unwrap_or_default();
|
||||
if !latest.contains('-') && current.contains('-') && latest_base == current_base {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if is_newer_version(latest_version, current_version) {
|
||||
return true;
|
||||
}
|
||||
if matches!(channel, UpdateChannel::Beta) {
|
||||
// SemVer ranks `0.6.60-beta.N` below `0.6.60`. A user who explicitly
|
||||
// opts into beta must still be able to move from the stable build of
|
||||
// that base version to its beta builds.
|
||||
let latest = normalize_version(latest_version);
|
||||
let current = normalize_version(current_version);
|
||||
let latest_base = latest.split(['-', '+']).next().unwrap_or_default();
|
||||
let current_base = current.split(['-', '+']).next().unwrap_or_default();
|
||||
return latest.contains('-') && latest_base == current_base && !current.contains('-');
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn canonical_release_notes(notes: Option<&str>) -> Option<String> {
|
||||
let notes = non_empty(notes)?;
|
||||
// Tauri's generated updater notes are a transport fallback, not the curated release notes.
|
||||
@@ -212,7 +303,10 @@ fn non_empty(value: Option<&str>) -> Option<&str> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_update_info, GithubReleaseMetadata, TauriRelease};
|
||||
use super::{
|
||||
build_update_info, normalize_version, release_identity_version, GithubReleaseMetadata, TauriRelease,
|
||||
UpdateChannel,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn parses_jdbc_plugin_metadata_from_latest_json() {
|
||||
@@ -234,12 +328,17 @@ mod tests {
|
||||
assert_eq!(jdbc.version, "0.1.3");
|
||||
assert_eq!(jdbc.protocol_version, 1);
|
||||
assert_eq!(jdbc.url, "https://github.com/t8y2/dbx/releases/latest/download/dbx-jdbc-plugin-latest.zip");
|
||||
assert_eq!(release.channel, UpdateChannel::Stable);
|
||||
assert!(release.is_promoted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_info_prefers_github_release_metadata() {
|
||||
let release = TauriRelease {
|
||||
version: "0.5.3".to_string(),
|
||||
channel: UpdateChannel::Stable,
|
||||
release_tag: None,
|
||||
is_promoted: false,
|
||||
notes: Some("See the assets below to download and install.".to_string()),
|
||||
jdbc_plugin: None,
|
||||
github: Some(GithubReleaseMetadata {
|
||||
@@ -263,6 +362,9 @@ mod tests {
|
||||
// 非中文界面用户:notes_en 命中时优先于 GitHub 中文 body,应用内更新提示展示英文
|
||||
let release = TauriRelease {
|
||||
version: "0.5.3".to_string(),
|
||||
channel: UpdateChannel::Stable,
|
||||
release_tag: None,
|
||||
is_promoted: false,
|
||||
notes: Some("See the assets below to download and install.".to_string()),
|
||||
jdbc_plugin: None,
|
||||
github: Some(GithubReleaseMetadata {
|
||||
@@ -282,6 +384,9 @@ mod tests {
|
||||
fn update_info_ignores_generated_notes_when_curated_notes_are_unavailable() {
|
||||
let release = TauriRelease {
|
||||
version: "0.5.3".to_string(),
|
||||
channel: UpdateChannel::Stable,
|
||||
release_tag: None,
|
||||
is_promoted: false,
|
||||
notes: Some("## What's Changed\n* generated item".to_string()),
|
||||
jdbc_plugin: None,
|
||||
github: None,
|
||||
@@ -293,22 +398,94 @@ mod tests {
|
||||
assert_eq!(info.release_notes, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beta_release_url_uses_the_prerelease_tag_without_github_metadata() {
|
||||
let release = TauriRelease {
|
||||
version: "0.6.60-beta.20261028.1".to_string(),
|
||||
channel: UpdateChannel::Beta,
|
||||
release_tag: Some("v0.6.60-beta.20261028.1".to_string()),
|
||||
is_promoted: false,
|
||||
notes: None,
|
||||
jdbc_plugin: None,
|
||||
github: None,
|
||||
notes_en: None,
|
||||
};
|
||||
|
||||
let info = build_update_info(release, "0.6.59");
|
||||
|
||||
assert_eq!(info.release_url, "https://github.com/t8y2/dbx/releases/tag/v0.6.60-beta.20261028.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_identity_follows_the_tag_of_a_promoted_beta() {
|
||||
let promoted = TauriRelease {
|
||||
version: "0.6.60".to_string(),
|
||||
channel: UpdateChannel::Stable,
|
||||
release_tag: Some("v0.6.60-beta.20261028.1".to_string()),
|
||||
is_promoted: true,
|
||||
notes: None,
|
||||
jdbc_plugin: None,
|
||||
github: None,
|
||||
notes_en: None,
|
||||
};
|
||||
// latest-en.json is keyed by the tag, so a promoted beta must not be matched by its base version.
|
||||
assert_eq!(normalize_version(release_identity_version(&promoted)), "0.6.60-beta.20261028.1");
|
||||
|
||||
let legacy = TauriRelease { release_tag: None, ..promoted };
|
||||
assert_eq!(release_identity_version(&legacy), "0.6.60");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_channel_does_not_reoffer_the_promoted_beta_build() {
|
||||
let release = TauriRelease {
|
||||
version: "0.6.60".to_string(),
|
||||
channel: UpdateChannel::Stable,
|
||||
release_tag: Some("v0.6.60-beta.20261028.1".to_string()),
|
||||
is_promoted: true,
|
||||
notes: None,
|
||||
jdbc_plugin: None,
|
||||
github: None,
|
||||
notes_en: None,
|
||||
};
|
||||
|
||||
let info = build_update_info(release, "0.6.60-beta.20261028.1");
|
||||
|
||||
assert!(!info.update_available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beta_channel_offers_same_base_prerelease_from_stable_build() {
|
||||
assert!(super::is_channel_update_available(UpdateChannel::Beta, "0.6.60-beta.20261028.1", "0.6.60"));
|
||||
assert!(super::is_channel_update_available(
|
||||
UpdateChannel::Beta,
|
||||
"0.6.60-beta.20261028.2",
|
||||
"0.6.60-beta.20261028.1"
|
||||
));
|
||||
assert!(!super::is_channel_update_available(UpdateChannel::Stable, "0.6.60", "0.6.60-beta.20261028.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_check_candidates_follow_selected_source() {
|
||||
assert_eq!(
|
||||
super::update_check_candidates(crate::DownloadSource::Official),
|
||||
super::update_check_candidates(crate::DownloadSource::Official, UpdateChannel::Stable),
|
||||
vec![
|
||||
"https://dl.dbxio.com/releases/stable/latest.json",
|
||||
"https://dl.dbxio.com/releases/latest/latest.json",
|
||||
"https://github.com/t8y2/dbx/releases/latest/download/latest.json",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
super::update_check_candidates(crate::DownloadSource::Cnb),
|
||||
super::update_check_candidates(crate::DownloadSource::Cnb, UpdateChannel::Stable),
|
||||
vec![
|
||||
"https://dl.dbxio.com/releases/stable/latest.json",
|
||||
"https://cnb.cool/dbxio.com/dbx/-/releases/latest/download/latest.json",
|
||||
"https://dl.dbxio.com/releases/latest/latest.json",
|
||||
"https://github.com/t8y2/dbx/releases/latest/download/latest.json",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
super::update_check_candidates(crate::DownloadSource::Official, UpdateChannel::Beta)[0],
|
||||
"https://dl.dbxio.com/releases/beta/latest.json"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,6 +562,12 @@ fn accepts_current_app_when_min_version_is_not_newer() {
|
||||
assert!(!is_app_version_compatible("0.5.14", "0.5.13"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_promoted_beta_for_same_base_minimum_version() {
|
||||
assert!(is_app_version_compatible("0.6.60", "0.6.60-beta.20261029.1"));
|
||||
assert!(!is_app_version_compatible("0.6.61", "0.6.60-beta.20261029.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_replace_moves_download_into_place() {
|
||||
let dir = test_path("atomic");
|
||||
|
||||
@@ -2751,7 +2751,7 @@ pub fn ensure_driver_app_version(
|
||||
}
|
||||
|
||||
pub fn is_app_version_compatible(min_app_version: &str, current_version: &str) -> bool {
|
||||
!dbx_platform::version::is_newer_version(min_app_version, current_version)
|
||||
dbx_platform::version::is_version_compatible(min_app_version, current_version)
|
||||
}
|
||||
|
||||
pub fn download_temp_path(dest: &std::path::Path) -> std::path::PathBuf {
|
||||
|
||||
@@ -12,6 +12,7 @@ test-support = ["host-prompts"]
|
||||
downloads = ["dep:reqwest", "dep:futures", "dep:serde"]
|
||||
|
||||
[dependencies]
|
||||
semver = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "socks"], optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
tokio = { version = "1", features = ["process", "time"] }
|
||||
|
||||
@@ -6,7 +6,20 @@ pub fn parse_version(version: &str) -> Vec<u64> {
|
||||
normalize_version(version).split(['.', '-', '+']).map(|part| part.parse::<u64>().unwrap_or(0)).collect()
|
||||
}
|
||||
|
||||
pub fn base_version(version: &str) -> String {
|
||||
normalize_version(version).split(['-', '+']).next().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
pub fn is_version_compatible(min_version: &str, current_version: &str) -> bool {
|
||||
!is_newer_version(&base_version(min_version), &base_version(current_version))
|
||||
}
|
||||
|
||||
pub fn is_newer_version(latest: &str, current: &str) -> bool {
|
||||
if let (Ok(latest), Ok(current)) =
|
||||
(semver::Version::parse(&normalize_version(latest)), semver::Version::parse(&normalize_version(current)))
|
||||
{
|
||||
return latest > current;
|
||||
}
|
||||
let latest_parts = parse_version(latest);
|
||||
let current_parts = parse_version(current);
|
||||
let max_len = latest_parts.len().max(current_parts.len());
|
||||
@@ -41,5 +54,18 @@ mod tests {
|
||||
assert!(is_newer_version("1.0.0", "0.9.9"));
|
||||
assert!(!is_newer_version("0.2.0", "0.2.0"));
|
||||
assert!(!is_newer_version("0.1.9", "0.2.0"));
|
||||
assert!(is_newer_version("0.6.60-beta.20261028.1", "0.6.59"));
|
||||
assert!(is_newer_version("0.6.60-beta.20261029.1", "0.6.60-beta.20261028.1"));
|
||||
assert!(is_newer_version("0.6.60", "0.6.60-beta.20261028.1"));
|
||||
assert!(!is_newer_version("0.6.59", "0.6.60-beta.20261028.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compares_application_compatibility_by_base_version() {
|
||||
assert_eq!(base_version("v0.6.60-beta.20261029.1"), "0.6.60");
|
||||
assert_eq!(base_version("0.6.60+build.1"), "0.6.60");
|
||||
assert!(is_version_compatible("0.6.60", "0.6.60-beta.20261029.1"));
|
||||
assert!(is_version_compatible("0.6.59", "0.6.60-beta.20261029.1"));
|
||||
assert!(!is_version_compatible("0.6.61", "0.6.60-beta.20261029.1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,19 @@ pub struct UpdateCheckParams {
|
||||
pub locale: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source: Option<dbx_core::DownloadSource>,
|
||||
#[serde(default)]
|
||||
pub channel: Option<dbx_core::update::UpdateChannel>,
|
||||
}
|
||||
|
||||
pub async fn check_for_updates(Query(params): Query<UpdateCheckParams>) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let locale = params.locale.unwrap_or_else(|| "zh-CN".to_string());
|
||||
let release =
|
||||
update::fetch_latest_release(&locale, params.source.unwrap_or_default()).await.map_err(AppError::from)?;
|
||||
let release = update::fetch_latest_release_for_channel(
|
||||
&locale,
|
||||
params.source.unwrap_or_default(),
|
||||
params.channel.unwrap_or_default(),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
let info = update::build_update_info(release, env!("CARGO_PKG_VERSION"));
|
||||
Ok(Json(serde_json::to_value(info).map_err(|e| AppError::from(e.to_string()))?))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { isDesktopVersionOnlyCargoLockChange } from "./release-lock.mjs";
|
||||
const repoRoot = new URL("..", import.meta.url).pathname;
|
||||
const releaseScript = join(repoRoot, "scripts/release.mjs");
|
||||
const releaseWorkflow = join(repoRoot, ".github/workflows/release.yml");
|
||||
const appPublishWorkflow = join(repoRoot, ".github/workflows/publish-packages.yml");
|
||||
const dailyBetaWorkflow = join(repoRoot, ".github/workflows/daily-beta-release.yml");
|
||||
const packagesWorkflow = join(repoRoot, ".github/workflows/mcp-release.yml");
|
||||
const vsignConfigPath = join(repoRoot, "src-tauri/tauri.vsign.conf.json");
|
||||
const appImageBundlerSetup = join(repoRoot, ".github/scripts/prepare-appimage-bundler.sh");
|
||||
@@ -72,6 +74,56 @@ test("rollback dry-run prints all affected channels without invoking GitHub", ()
|
||||
assert.match(result.stdout, /does not downgrade clients/);
|
||||
});
|
||||
|
||||
test("app release workflows preserve beta tags and stable package URLs", () => {
|
||||
const release = readFileSync(releaseWorkflow, "utf8");
|
||||
const publish = readFileSync(appPublishWorkflow, "utf8");
|
||||
const daily = readFileSync(dailyBetaWorkflow, "utf8");
|
||||
|
||||
assert.ok(release.includes("$version = $tagVersion -replace '-beta\\..*$', ''"));
|
||||
assert.match(release, /--draft=false --prerelease/);
|
||||
assert.match(publish, /__TAG__/);
|
||||
assert.match(publish, /needs: \[publish-stable-alias, validate-candidate\]/);
|
||||
assert.match(publish, /releases\/latest\/download\/\$\{PRODUCT_NAME\}_\\\$version/);
|
||||
assert.match(publish, /Publish stable manifest to GitHub Release/);
|
||||
assert.match(publish, /prepare-channel-manifest\.mjs[\s\S]+stable \"\$TAG\" true/);
|
||||
assert.match(publish, /gh release upload \"\$TAG\" \"\$manifest_dir\/latest\.json\"/);
|
||||
assert.match(publish, /prune-r2-releases\.sh stable "\$\{\{ needs\.upload-r2\.outputs\.tag \}\}"/);
|
||||
assert.match(release, /prune-r2-releases\.sh beta "\$\{\{ inputs\.release_tag \|\| github\.ref_name \}\}"/);
|
||||
assert.match(release, /gh release edit "\$\{RELEASE_TAG\}" --repo "\$\{GITHUB_REPOSITORY\}" --draft=false --prerelease\n/);
|
||||
assert.doesNotMatch(release, /--draft=false --prerelease=false --latest/);
|
||||
assert.match(release, /if: \$\{\{ !contains\(env\.RELEASE_TAG, '-beta\.'\) \}\}\n[\s\S]+gh workflow run publish-packages\.yml[\s\S]+-f tag="\$\{RELEASE_TAG\}"/);
|
||||
assert.match(release, /permissions:\n contents: write\n actions: write/);
|
||||
assert.match(publish, /approve-promotion:[\s\S]+if: \$\{\{ contains\(inputs\.tag, '-beta\.'\) \}\}[\s\S]+environment: release-promotion/);
|
||||
assert.match(publish, /needs\.approve-promotion\.result/);
|
||||
assert.match(publish, /edit_args\+=\(--title "DBX v\$\{\{ needs\.validate-candidate\.outputs\.version \}\}"\)/);
|
||||
assert.match(publish, /sync-source-version:[\s\S]+needs: \[validate-candidate, promote-release\][\s\S]+ref: main[\s\S]+MCP_RELEASE_TOKEN/);
|
||||
assert.match(publish, /set-release-version\.mjs "v\$\{VERSION\}"/);
|
||||
assert.match(publish, /promote-docker:[\s\S]+is_beta == 'true'[\s\S]+needs: \[validate-candidate, promote-release\]/);
|
||||
assert.match(publish, /imagetools create -t "\$\{image\}:\$\{STABLE_VERSION\}" -t "\$\{image\}:latest" "\$\{image\}:\$\{source_version\}"/);
|
||||
assert.doesNotMatch(publish, /Stable publication requires a non-prerelease/);
|
||||
assert.doesNotMatch(publish, /no promotion needed/);
|
||||
assert.match(release, /prepare-msi-config\.mjs "\$\{env:RELEASE_TAG\}" src-tauri\/tauri\.msi-version\.conf\.json/);
|
||||
assert.match(release, /--config src-tauri\/tauri\.msi-version\.conf\.json/);
|
||||
assert.doesNotMatch(release, /--bundles nsis' \|\| ''/);
|
||||
assert.match(release, /if \(\$msiInstallers\.Count -eq 0\)/);
|
||||
assert.match(publish, /x64-setup\.exe arm64-setup\.exe x64_en-US\.msi arm64_en-US\.msi/);
|
||||
assert.match(daily, /schedule:/);
|
||||
assert.match(daily, /gh release create "\$tag"/);
|
||||
assert.match(daily, /-beta\.\$\{date_suffix\}\.\$\{sequence\}/);
|
||||
assert.match(daily, /gh workflow run release\.yml/);
|
||||
assert.match(daily, /MCP_RELEASE_TOKEN/);
|
||||
assert.match(daily, /matches stable; publishing a daily beta build/);
|
||||
assert.match(daily, /next_patch="\$\{BASH_REMATCH\[1\]\}\.\$\{BASH_REMATCH\[2\]\}\.\$\(\(BASH_REMATCH\[3\] \+ 1\)\)"/);
|
||||
assert.match(daily, /sort -V \| tail -n 1/);
|
||||
assert.match(daily, /force:[\s\S]+type: boolean/);
|
||||
assert.match(daily, /compare\/\$\{built_tag\}\.\.\.\$\{GITHUB_SHA\}/);
|
||||
assert.match(daily, /contains\("\[skip ci\]"\) \| not/);
|
||||
assert.match(daily, /No new commits since \$\{built_tag\}; skipping the daily beta build/);
|
||||
assert.match(release, /workflow_dispatch:/);
|
||||
assert.match(release, /release_tag:/);
|
||||
assert.match(release, /RELEASE_TAG:/);
|
||||
});
|
||||
|
||||
test("rollback dispatches each distribution workflow after validation", () => {
|
||||
const mockBin = createMockGh();
|
||||
const logPath = join(mockBin, "gh.log");
|
||||
|
||||
@@ -10,6 +10,7 @@ use super::{
|
||||
update_cache::{self, CacheRecord},
|
||||
update_portable,
|
||||
};
|
||||
pub use dbx_core::update::UpdateChannel;
|
||||
pub use dbx_core::update::UpdateInfo;
|
||||
use semver::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -17,10 +18,14 @@ use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri_plugin_updater::{Update, UpdaterBuilder, UpdaterExt};
|
||||
|
||||
const OFFICIAL_UPDATE_ENDPOINTS: [&str; 2] = [
|
||||
"https://dl.dbxio.com/releases/latest/latest.json",
|
||||
"https://dl.dbxio.com/releases/stable/latest.json",
|
||||
"https://github.com/t8y2/dbx/releases/latest/download/latest.json",
|
||||
];
|
||||
const BETA_UPDATE_ENDPOINTS: [&str; 1] = ["https://dl.dbxio.com/releases/beta/latest.json"];
|
||||
#[cfg(test)]
|
||||
const R2_LATEST_RELEASE_DOWNLOAD_PREFIX: &str = "https://dl.dbxio.com/releases/latest/";
|
||||
const R2_STABLE_RELEASE_DOWNLOAD_PREFIX: &str = "https://dl.dbxio.com/releases/stable/latest/";
|
||||
const R2_BETA_RELEASE_DOWNLOAD_PREFIX: &str = "https://dl.dbxio.com/releases/beta/latest/";
|
||||
const CNB_RELEASE_DOWNLOAD_PREFIX: &str = "https://cnb.cool/dbxio.com/dbx/-/releases/download/";
|
||||
const GITHUB_RELEASE_DOWNLOAD_PREFIX: &str = "https://github.com/t8y2/dbx/releases/download/";
|
||||
const UPDATE_DOWNLOAD_PROGRESS_EVENT: &str = "update-download-progress";
|
||||
@@ -35,6 +40,12 @@ fn dbx_updater_builder(app: &AppHandle) -> UpdaterBuilder {
|
||||
app.updater_builder().on_before_exit(move || crate::cleanup_before_updater_forced_exit(&cleanup_app))
|
||||
}
|
||||
|
||||
fn with_update_channel(builder: UpdaterBuilder, channel: UpdateChannel) -> UpdaterBuilder {
|
||||
builder.version_comparator(move |current, update| {
|
||||
dbx_core::update::is_channel_update_available(channel, &update.version.to_string(), ¤t.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UpdateDownloadSource {
|
||||
@@ -238,15 +249,22 @@ impl UpdateDownloadSource {
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoints(&self, latest_version: Option<&str>) -> Result<Vec<String>, String> {
|
||||
fn endpoints(&self, latest_version: Option<&str>, channel: UpdateChannel) -> Result<Vec<String>, String> {
|
||||
let channel_endpoints: Vec<String> = match channel {
|
||||
UpdateChannel::Stable => OFFICIAL_UPDATE_ENDPOINTS.iter().map(|endpoint| (*endpoint).to_string()).collect(),
|
||||
UpdateChannel::Beta => BETA_UPDATE_ENDPOINTS.iter().map(|endpoint| (*endpoint).to_string()).collect(),
|
||||
};
|
||||
match self {
|
||||
Self::Official => Ok(OFFICIAL_UPDATE_ENDPOINTS.iter().map(|endpoint| endpoint.to_string()).collect()),
|
||||
Self::Official => Ok(channel_endpoints),
|
||||
Self::Cnb => {
|
||||
if matches!(channel, UpdateChannel::Beta) {
|
||||
return Ok(channel_endpoints);
|
||||
}
|
||||
let version =
|
||||
latest_version.ok_or_else(|| "Latest version is required for CNB updates.".to_string())?;
|
||||
Ok(vec![
|
||||
format!("{CNB_RELEASE_DOWNLOAD_PREFIX}{}/latest.json", tag_version(version)),
|
||||
OFFICIAL_UPDATE_ENDPOINTS[0].to_string(),
|
||||
channel_endpoints[0].clone(),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -278,18 +296,28 @@ impl UpdateDownloadSource {
|
||||
&self,
|
||||
latest_version: &str,
|
||||
arch: &str,
|
||||
channel: UpdateChannel,
|
||||
release_tag: Option<&str>,
|
||||
) -> Result<Vec<PortableAssetCandidate>, String> {
|
||||
let normalized_version = latest_version.trim().trim_start_matches('v');
|
||||
let filename = update_portable::portable_asset_name(normalized_version, arch)?;
|
||||
let tag = tag_version(normalized_version);
|
||||
let tag = release_tag
|
||||
.map(str::trim)
|
||||
.filter(|tag| !tag.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| tag_version(normalized_version));
|
||||
let r2_prefix = match channel {
|
||||
UpdateChannel::Stable => R2_STABLE_RELEASE_DOWNLOAD_PREFIX,
|
||||
UpdateChannel::Beta => R2_BETA_RELEASE_DOWNLOAD_PREFIX,
|
||||
};
|
||||
let archive_urls = match self {
|
||||
Self::Official => vec![
|
||||
format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}{filename}"),
|
||||
format!("{GITHUB_RELEASE_DOWNLOAD_PREFIX}{tag}/{filename}"),
|
||||
],
|
||||
Self::Official => {
|
||||
vec![format!("{r2_prefix}{filename}"), format!("{GITHUB_RELEASE_DOWNLOAD_PREFIX}{tag}/{filename}")]
|
||||
}
|
||||
Self::Cnb => vec![
|
||||
format!("{CNB_RELEASE_DOWNLOAD_PREFIX}{tag}/{filename}"),
|
||||
format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}{filename}"),
|
||||
format!("{r2_prefix}{filename}"),
|
||||
format!("{GITHUB_RELEASE_DOWNLOAD_PREFIX}{tag}/{filename}"),
|
||||
],
|
||||
};
|
||||
Ok(archive_urls
|
||||
@@ -298,24 +326,41 @@ impl UpdateDownloadSource {
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn installer_asset_candidates(&self, download_url: &str, latest_version: Option<&str>) -> Vec<String> {
|
||||
let filename = download_url.rsplit('/').next().filter(|name| !name.is_empty()).unwrap_or("");
|
||||
fn installer_asset_candidates(
|
||||
&self,
|
||||
download_url: &str,
|
||||
latest_version: Option<&str>,
|
||||
channel: UpdateChannel,
|
||||
release_tag: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let download_path = download_url.split_once('?').map(|(url, _)| url).unwrap_or(download_url);
|
||||
let download_path = download_path.split_once('#').map(|(url, _)| url).unwrap_or(download_path);
|
||||
let filename = download_path.rsplit('/').next().filter(|name| !name.is_empty()).unwrap_or("");
|
||||
let r2_prefix = match channel {
|
||||
UpdateChannel::Stable => R2_STABLE_RELEASE_DOWNLOAD_PREFIX,
|
||||
UpdateChannel::Beta => R2_BETA_RELEASE_DOWNLOAD_PREFIX,
|
||||
};
|
||||
|
||||
let tag = latest_version.map(tag_version).unwrap_or_else(|| {
|
||||
if let Some(pos) = download_url.find("/releases/download/") {
|
||||
let rest = &download_url[pos + "/releases/download/".len()..];
|
||||
if let Some(tag_end) = rest.find('/') {
|
||||
return rest[..tag_end].to_string();
|
||||
let tag = release_tag
|
||||
.map(str::trim)
|
||||
.filter(|tag| !tag.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| latest_version.map(tag_version))
|
||||
.unwrap_or_else(|| {
|
||||
if let Some(pos) = download_url.find("/releases/download/") {
|
||||
let rest = &download_url[pos + "/releases/download/".len()..];
|
||||
if let Some(tag_end) = rest.find('/') {
|
||||
return rest[..tag_end].to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
"".to_string()
|
||||
});
|
||||
"".to_string()
|
||||
});
|
||||
|
||||
let raw_candidates = match self {
|
||||
Self::Official => {
|
||||
let mut urls = Vec::new();
|
||||
if !filename.is_empty() {
|
||||
urls.push(format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}{filename}"));
|
||||
urls.push(format!("{r2_prefix}{filename}"));
|
||||
}
|
||||
urls.push(download_url.to_string());
|
||||
if !tag.is_empty() && !filename.is_empty() {
|
||||
@@ -331,7 +376,7 @@ impl UpdateDownloadSource {
|
||||
urls.push(format!("{CNB_RELEASE_DOWNLOAD_PREFIX}{tag}/{filename}"));
|
||||
}
|
||||
if !filename.is_empty() {
|
||||
urls.push(format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}{filename}"));
|
||||
urls.push(format!("{r2_prefix}{filename}"));
|
||||
}
|
||||
urls.push(download_url.to_string());
|
||||
urls
|
||||
@@ -357,13 +402,26 @@ fn tag_version(version: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn release_tag_for_version(version: &Version, candidate: Option<String>) -> Result<String, String> {
|
||||
let tag = candidate.unwrap_or_else(|| tag_version(&version.to_string()));
|
||||
let normalized = tag.strip_prefix('v').unwrap_or(&tag);
|
||||
let parsed = Version::parse(normalized).map_err(|_| "Invalid update release tag.".to_string())?;
|
||||
if (parsed.major, parsed.minor, parsed.patch) != (version.major, version.minor, version.patch) {
|
||||
return Err("Update release tag does not match the downloaded version.".into());
|
||||
}
|
||||
Ok(format!("v{normalized}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates(
|
||||
locale: Option<String>,
|
||||
source: Option<dbx_core::DownloadSource>,
|
||||
channel: Option<UpdateChannel>,
|
||||
) -> Result<UpdateInfo, String> {
|
||||
let locale = locale.unwrap_or_else(|| "zh-CN".to_string());
|
||||
let release = dbx_core::update::fetch_latest_release(&locale, source.unwrap_or_default()).await?;
|
||||
let channel = channel.unwrap_or_default();
|
||||
let release =
|
||||
dbx_core::update::fetch_latest_release_for_channel(&locale, source.unwrap_or_default(), channel).await?;
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
let mut info = dbx_core::update::build_update_info(release, current_version);
|
||||
info.portable_mode = crate::data_dir::is_portable_mode();
|
||||
@@ -444,7 +502,7 @@ fn restore_cached(app: &AppHandle) -> Result<Option<CachedUpdate>, String> {
|
||||
)?;
|
||||
ReadyUpdate::Portable { archive: bytes, version }
|
||||
} else {
|
||||
let update = dbx_updater_builder(app)
|
||||
let update = with_update_channel(dbx_updater_builder(app), record.info.channel)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?
|
||||
.restore_update(record.manifest.clone().ok_or("Missing cached release metadata")?)
|
||||
@@ -504,15 +562,18 @@ pub async fn download_update(
|
||||
app: AppHandle,
|
||||
state: tauri::State<'_, PendingUpdateState>,
|
||||
source: UpdateDownloadSource,
|
||||
channel: UpdateChannel,
|
||||
latest_version: String,
|
||||
attempt_id: String,
|
||||
release_notes: Option<String>,
|
||||
release_tag: Option<String>,
|
||||
) -> Result<DownloadedUpdate, String> {
|
||||
let portable_mode = crate::data_dir::is_portable_mode();
|
||||
if requires_manual_update(IS_WINDOWS_7_TARGET) {
|
||||
return Err("Windows 7 builds must be updated with the dedicated Windows 7 offline installer.".to_string());
|
||||
}
|
||||
let version = update_portable::validate_requested_portable_version(&latest_version, env!("CARGO_PKG_VERSION"))?;
|
||||
let release_tag = release_tag_for_version(&version, release_tag)?;
|
||||
if attempt_id.is_empty() || attempt_id.len() > 128 {
|
||||
return Err("Invalid download attempt identifier.".into());
|
||||
}
|
||||
@@ -522,26 +583,46 @@ pub async fn download_update(
|
||||
update_cache::discard(&cache_root)?;
|
||||
update_cache::ensure_writable(&cache_root)?;
|
||||
let (ready, manifest, signature, notes) = if portable_mode {
|
||||
let (archive, signature) =
|
||||
download_portable_update_inner(&app, &source, &version, &attempt_id, &cancellation).await?;
|
||||
let (archive, signature) = download_portable_update_inner(
|
||||
&app,
|
||||
&source,
|
||||
channel,
|
||||
&version,
|
||||
&release_tag,
|
||||
&attempt_id,
|
||||
&cancellation,
|
||||
)
|
||||
.await?;
|
||||
(ReadyUpdate::Portable { archive, version: version.clone() }, None, Some(signature), String::new())
|
||||
} else {
|
||||
let (update, bytes) =
|
||||
download_update_inner(&app, &source, Some(&latest_version), &attempt_id, &cancellation).await?;
|
||||
let (update, bytes) = download_update_inner(
|
||||
&app,
|
||||
&source,
|
||||
channel,
|
||||
Some(&latest_version),
|
||||
Some(&release_tag),
|
||||
&attempt_id,
|
||||
&cancellation,
|
||||
)
|
||||
.await?;
|
||||
let manifest = Some(update.raw_json.clone());
|
||||
let notes = update.body.clone().unwrap_or_default();
|
||||
(ReadyUpdate::Installer { update: Box::new(update), bytes }, manifest, None, notes)
|
||||
};
|
||||
let release_url = format!("https://github.com/t8y2/dbx/releases/tag/{release_tag}");
|
||||
let info = DownloadedUpdate {
|
||||
cache_id: uuid::Uuid::new_v4().to_string(),
|
||||
version: version.to_string(),
|
||||
portable_mode,
|
||||
release_url: format!("https://github.com/t8y2/dbx/releases/tag/v{version}"),
|
||||
release_url,
|
||||
release_notes: release_notes.unwrap_or(notes),
|
||||
downloaded_at: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_millis() as u64,
|
||||
channel,
|
||||
release_tag: Some(release_tag),
|
||||
is_promoted: matches!(channel, UpdateChannel::Stable),
|
||||
};
|
||||
let bytes = match &ready {
|
||||
ReadyUpdate::Installer { bytes, .. } => bytes,
|
||||
@@ -574,17 +655,19 @@ pub async fn download_update(
|
||||
async fn download_update_inner(
|
||||
app: &AppHandle,
|
||||
source: &UpdateDownloadSource,
|
||||
channel: UpdateChannel,
|
||||
latest_version: Option<&str>,
|
||||
release_tag: Option<&str>,
|
||||
attempt_id: &str,
|
||||
cancellation: &Arc<DownloadCancellation>,
|
||||
) -> Result<(Update, Vec<u8>), String> {
|
||||
let endpoint_urls = source.endpoints(latest_version)?;
|
||||
let endpoint_urls = source.endpoints(latest_version, channel)?;
|
||||
println!("[DBX updater] checking from {} endpoints: {}", source.label(), endpoint_urls.join(", "));
|
||||
let mut endpoints = Vec::with_capacity(endpoint_urls.len());
|
||||
for endpoint_url in endpoint_urls {
|
||||
endpoints.push(endpoint_url.parse().map_err(|e| format!("Invalid update endpoint: {e}"))?);
|
||||
}
|
||||
let mut builder = dbx_updater_builder(app)
|
||||
let mut builder = with_update_channel(dbx_updater_builder(app), channel)
|
||||
.endpoints(endpoints)
|
||||
.map_err(|e| format!("Failed to configure updater endpoint: {e}"))?;
|
||||
|
||||
@@ -609,7 +692,8 @@ async fn download_update_inner(
|
||||
if Some(update.version.as_str()) != latest_version.map(|v| v.trim().trim_start_matches('v')) {
|
||||
return Err("Update version changed; check for updates again.".into());
|
||||
}
|
||||
let candidates = source.installer_asset_candidates(update.download_url.as_str(), latest_version);
|
||||
let candidates =
|
||||
source.installer_asset_candidates(update.download_url.as_str(), latest_version, channel, release_tag);
|
||||
println!("[DBX updater] candidates for installer download: {:?}", candidates);
|
||||
|
||||
let mut failures = Vec::new();
|
||||
@@ -705,12 +789,15 @@ async fn download_update_inner(
|
||||
async fn download_portable_update_inner(
|
||||
app: &AppHandle,
|
||||
source: &UpdateDownloadSource,
|
||||
channel: UpdateChannel,
|
||||
latest_version: &Version,
|
||||
release_tag: &str,
|
||||
attempt_id: &str,
|
||||
cancellation: &Arc<DownloadCancellation>,
|
||||
) -> Result<(Vec<u8>, String), String> {
|
||||
let latest_version_text = latest_version.to_string();
|
||||
let candidates = source.portable_asset_candidates(&latest_version_text, std::env::consts::ARCH)?;
|
||||
let candidates =
|
||||
source.portable_asset_candidates(&latest_version_text, std::env::consts::ARCH, channel, Some(release_tag))?;
|
||||
let client = portable_update_http_client()?;
|
||||
let mut failures = Vec::new();
|
||||
|
||||
@@ -959,10 +1046,12 @@ fn schedule_portable_update_exit(app: AppHandle) {
|
||||
mod tests {
|
||||
use super::{
|
||||
requires_manual_update, tag_version, wait_for_download_step, wait_for_progressing_download,
|
||||
DownloadCancellation, PendingUpdateState, UpdateDownloadProgressGate, UpdateDownloadSource,
|
||||
DownloadCancellation, PendingUpdateState, UpdateChannel, UpdateDownloadProgressGate, UpdateDownloadSource,
|
||||
CNB_RELEASE_DOWNLOAD_PREFIX, DOWNLOAD_CANCELED_ERROR, GITHUB_RELEASE_DOWNLOAD_PREFIX,
|
||||
OFFICIAL_UPDATE_ENDPOINTS, R2_LATEST_RELEASE_DOWNLOAD_PREFIX,
|
||||
OFFICIAL_UPDATE_ENDPOINTS, R2_BETA_RELEASE_DOWNLOAD_PREFIX, R2_LATEST_RELEASE_DOWNLOAD_PREFIX,
|
||||
R2_STABLE_RELEASE_DOWNLOAD_PREFIX,
|
||||
};
|
||||
use semver::Version;
|
||||
use std::{future::pending, sync::Arc, time::Duration};
|
||||
|
||||
fn cached_fixture() -> super::CachedUpdate {
|
||||
@@ -979,6 +1068,9 @@ mod tests {
|
||||
release_url: String::new(),
|
||||
release_notes: String::new(),
|
||||
downloaded_at: 0,
|
||||
channel: UpdateChannel::Stable,
|
||||
release_tag: None,
|
||||
is_promoted: true,
|
||||
},
|
||||
os: std::env::consts::OS.into(),
|
||||
arch: std::env::consts::ARCH.into(),
|
||||
@@ -989,6 +1081,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beta_release_tag_is_preserved_and_must_match_version() {
|
||||
let version = Version::parse("0.6.60").unwrap();
|
||||
assert_eq!(
|
||||
super::release_tag_for_version(&version, Some("v0.6.60-beta.20261028.1".into())).unwrap(),
|
||||
"v0.6.60-beta.20261028.1"
|
||||
);
|
||||
assert!(super::release_tag_for_version(&version, Some("v0.6.61-beta.20261028.1".into())).is_err());
|
||||
assert_eq!(super::release_tag_for_version(&version, None).unwrap(), "v0.6.60");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_restore_keeps_a_same_base_beta_download_installable() {
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
let mut context = tauri::test::mock_context(tauri::test::noop_assets());
|
||||
context.config_mut().plugins.0.insert("updater".into(), serde_json::json!({ "pubkey": "" }));
|
||||
let app =
|
||||
tauri::test::mock_builder().plugin(tauri_plugin_updater::Builder::new().build()).build(context).unwrap();
|
||||
let current = &app.package_info().version;
|
||||
let manifest = serde_json::json!({
|
||||
"version": format!("{}.{}.{}-beta.20261029.1", current.major, current.minor, current.patch),
|
||||
"platforms": {
|
||||
"test-target": { "url": "https://example.com/dbx.tar.gz", "signature": "test-signature" }
|
||||
}
|
||||
});
|
||||
let restore = |channel| {
|
||||
super::with_update_channel(app.updater_builder(), channel)
|
||||
.target("test-target")
|
||||
.endpoints(vec!["https://example.com/latest.json".parse().unwrap()])
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap()
|
||||
.restore_update(manifest.clone())
|
||||
.unwrap()
|
||||
};
|
||||
assert!(restore(UpdateChannel::Beta).is_some());
|
||||
assert!(restore(UpdateChannel::Stable).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_cache_identity_and_double_install_are_rejected() {
|
||||
let state = PendingUpdateState::default();
|
||||
@@ -1053,13 +1184,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn builds_official_update_endpoints() {
|
||||
let endpoints = UpdateDownloadSource::Official.endpoints(None).unwrap();
|
||||
let endpoints = UpdateDownloadSource::Official.endpoints(None, UpdateChannel::Stable).unwrap();
|
||||
assert_eq!(endpoints, OFFICIAL_UPDATE_ENDPOINTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_cnb_update_endpoint_for_tag() {
|
||||
let endpoints = UpdateDownloadSource::Cnb.endpoints(Some("0.5.39")).unwrap();
|
||||
let endpoints = UpdateDownloadSource::Cnb.endpoints(Some("0.5.39"), UpdateChannel::Stable).unwrap();
|
||||
assert_eq!(
|
||||
endpoints,
|
||||
vec![format!("{CNB_RELEASE_DOWNLOAD_PREFIX}v0.5.39/latest.json"), OFFICIAL_UPDATE_ENDPOINTS[0].to_string()]
|
||||
@@ -1085,11 +1216,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn builds_signed_official_portable_asset_candidates() {
|
||||
let candidates = UpdateDownloadSource::Official.portable_asset_candidates("0.5.64", "x86_64").unwrap();
|
||||
let candidates = UpdateDownloadSource::Official
|
||||
.portable_asset_candidates("0.5.64", "x86_64", UpdateChannel::Stable, None)
|
||||
.unwrap();
|
||||
assert_eq!(candidates.len(), 2);
|
||||
assert_eq!(
|
||||
candidates[0].archive_url,
|
||||
format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}DBX_0.5.64_x64-portable.zip")
|
||||
format!("{R2_STABLE_RELEASE_DOWNLOAD_PREFIX}DBX_0.5.64_x64-portable.zip")
|
||||
);
|
||||
assert_eq!(
|
||||
candidates[1].archive_url,
|
||||
@@ -1100,14 +1233,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn builds_cnb_portable_asset_candidate_with_r2_fallback() {
|
||||
let candidates = UpdateDownloadSource::Cnb.portable_asset_candidates("v0.5.64", "aarch64").unwrap();
|
||||
let candidates = UpdateDownloadSource::Cnb
|
||||
.portable_asset_candidates("v0.5.64", "aarch64", UpdateChannel::Stable, None)
|
||||
.unwrap();
|
||||
assert_eq!(candidates.len(), 3);
|
||||
assert_eq!(
|
||||
candidates[0].archive_url,
|
||||
format!("{CNB_RELEASE_DOWNLOAD_PREFIX}v0.5.64/DBX_0.5.64_arm64-portable.zip")
|
||||
);
|
||||
assert_eq!(
|
||||
candidates[1].archive_url,
|
||||
format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}DBX_0.5.64_arm64-portable.zip")
|
||||
format!("{R2_STABLE_RELEASE_DOWNLOAD_PREFIX}DBX_0.5.64_arm64-portable.zip")
|
||||
);
|
||||
assert_eq!(
|
||||
candidates[2].archive_url,
|
||||
format!("{GITHUB_RELEASE_DOWNLOAD_PREFIX}v0.5.64/DBX_0.5.64_arm64-portable.zip")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1116,10 +1256,12 @@ mod tests {
|
||||
let candidates = UpdateDownloadSource::Cnb.installer_asset_candidates(
|
||||
"https://github.com/t8y2/dbx/releases/download/v0.5.64/DBX_0.5.64_aarch64.dmg",
|
||||
Some("0.5.64"),
|
||||
UpdateChannel::Stable,
|
||||
None,
|
||||
);
|
||||
assert_eq!(candidates.len(), 3);
|
||||
assert_eq!(candidates[0], "https://cnb.cool/dbxio.com/dbx/-/releases/download/v0.5.64/DBX_0.5.64_aarch64.dmg");
|
||||
assert_eq!(candidates[1], format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}DBX_0.5.64_aarch64.dmg"));
|
||||
assert_eq!(candidates[1], format!("{R2_STABLE_RELEASE_DOWNLOAD_PREFIX}DBX_0.5.64_aarch64.dmg"));
|
||||
assert_eq!(candidates[2], "https://github.com/t8y2/dbx/releases/download/v0.5.64/DBX_0.5.64_aarch64.dmg");
|
||||
}
|
||||
|
||||
@@ -1128,13 +1270,61 @@ mod tests {
|
||||
let candidates = UpdateDownloadSource::Official.installer_asset_candidates(
|
||||
"https://github.com/t8y2/dbx/releases/download/v0.5.64/DBX_0.5.64_aarch64.dmg",
|
||||
Some("0.5.64"),
|
||||
UpdateChannel::Stable,
|
||||
None,
|
||||
);
|
||||
assert_eq!(candidates.len(), 2);
|
||||
assert_eq!(candidates[0], format!("{R2_LATEST_RELEASE_DOWNLOAD_PREFIX}DBX_0.5.64_aarch64.dmg"));
|
||||
assert_eq!(candidates[0], format!("{R2_STABLE_RELEASE_DOWNLOAD_PREFIX}DBX_0.5.64_aarch64.dmg"));
|
||||
assert_eq!(candidates[1], "https://github.com/t8y2/dbx/releases/download/v0.5.64/DBX_0.5.64_aarch64.dmg");
|
||||
assert!(!candidates.iter().any(|url| url.contains("cnb.cool")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beta_installer_fallbacks_use_the_prerelease_release_tag() {
|
||||
let candidates = UpdateDownloadSource::Cnb.installer_asset_candidates(
|
||||
"https://dl.dbxio.com/releases/beta/v0.6.60-beta.20261028.1/DBX_0.6.60_x64-setup.exe?v=v0.6.60-beta.20261028.1",
|
||||
Some("0.6.60"),
|
||||
UpdateChannel::Beta,
|
||||
Some("v0.6.60-beta.20261028.1"),
|
||||
);
|
||||
assert_eq!(
|
||||
candidates[0],
|
||||
"https://cnb.cool/dbxio.com/dbx/-/releases/download/v0.6.60-beta.20261028.1/DBX_0.6.60_x64-setup.exe"
|
||||
);
|
||||
assert_eq!(candidates[1], format!("{R2_BETA_RELEASE_DOWNLOAD_PREFIX}DBX_0.6.60_x64-setup.exe"));
|
||||
assert_eq!(
|
||||
candidates[2],
|
||||
"https://dl.dbxio.com/releases/beta/v0.6.60-beta.20261028.1/DBX_0.6.60_x64-setup.exe?v=v0.6.60-beta.20261028.1"
|
||||
);
|
||||
assert!(!candidates.iter().any(|url| url.contains("releases/download/v0.6.60/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beta_portable_candidates_never_use_stable_alias() {
|
||||
let candidates = UpdateDownloadSource::Official
|
||||
.portable_asset_candidates("0.6.60", "x86_64", UpdateChannel::Beta, None)
|
||||
.unwrap();
|
||||
assert_eq!(candidates[0].archive_url, format!("{R2_BETA_RELEASE_DOWNLOAD_PREFIX}DBX_0.6.60_x64-portable.zip"));
|
||||
assert!(!candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.archive_url.starts_with(R2_LATEST_RELEASE_DOWNLOAD_PREFIX)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beta_portable_fallbacks_use_the_prerelease_release_tag() {
|
||||
let candidates = UpdateDownloadSource::Cnb
|
||||
.portable_asset_candidates("0.6.60", "x86_64", UpdateChannel::Beta, Some("v0.6.60-beta.20261028.1"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
candidates[0].archive_url,
|
||||
format!("{CNB_RELEASE_DOWNLOAD_PREFIX}v0.6.60-beta.20261028.1/DBX_0.6.60_x64-portable.zip")
|
||||
);
|
||||
assert_eq!(
|
||||
candidates[2].archive_url,
|
||||
format!("{GITHUB_RELEASE_DOWNLOAD_PREFIX}v0.6.60-beta.20261028.1/DBX_0.6.60_x64-portable.zip")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_progress_only_when_the_visible_percentage_changes() {
|
||||
let mut gate = UpdateDownloadProgressGate::default();
|
||||
|
||||
@@ -7,6 +7,10 @@ use std::{
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
fn default_is_promoted() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct DownloadedUpdate {
|
||||
pub cache_id: String,
|
||||
@@ -15,6 +19,14 @@ pub struct DownloadedUpdate {
|
||||
pub release_url: String,
|
||||
pub release_notes: String,
|
||||
pub downloaded_at: u64,
|
||||
#[serde(default)]
|
||||
pub channel: dbx_core::update::UpdateChannel,
|
||||
#[serde(default)]
|
||||
pub release_tag: Option<String>,
|
||||
// Caches written before channel metadata existed were stable updates. Keep
|
||||
// their compatibility metadata consistent with that historical meaning.
|
||||
#[serde(default = "default_is_promoted")]
|
||||
pub is_promoted: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -185,6 +197,22 @@ pub(super) fn root(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_stable_cache_defaults_to_promoted() {
|
||||
let legacy = r#"{
|
||||
"cache_id":"legacy",
|
||||
"version":"0.6.59",
|
||||
"portable_mode":false,
|
||||
"release_url":"",
|
||||
"release_notes":"",
|
||||
"downloaded_at":1
|
||||
}"#;
|
||||
let info: DownloadedUpdate = serde_json::from_str(legacy).unwrap();
|
||||
assert_eq!(info.channel, dbx_core::update::UpdateChannel::Stable);
|
||||
assert!(info.is_promoted);
|
||||
assert!(info.release_tag.is_none());
|
||||
}
|
||||
|
||||
// User screenshots only carry the message, so a cache failure has to name
|
||||
// the step and the exact path to be actionable.
|
||||
#[test]
|
||||
@@ -239,6 +267,9 @@ mod tests {
|
||||
release_url: String::new(),
|
||||
release_notes: String::new(),
|
||||
downloaded_at: 1,
|
||||
channel: dbx_core::update::UpdateChannel::Stable,
|
||||
release_tag: None,
|
||||
is_promoted: true,
|
||||
},
|
||||
os: std::env::consts::OS.into(),
|
||||
arch: std::env::consts::ARCH.into(),
|
||||
@@ -268,6 +299,9 @@ mod tests {
|
||||
release_url: String::new(),
|
||||
release_notes: String::new(),
|
||||
downloaded_at: 1,
|
||||
channel: dbx_core::update::UpdateChannel::Stable,
|
||||
release_tag: None,
|
||||
is_promoted: true,
|
||||
},
|
||||
os: std::env::consts::OS.into(),
|
||||
arch: std::env::consts::ARCH.into(),
|
||||
|
||||
@@ -35,7 +35,7 @@ pub(super) fn ensure_portable_version_is_newer(
|
||||
current_version: &str,
|
||||
) -> Result<(), String> {
|
||||
let current = parse_portable_version(current_version, "current app")?;
|
||||
if requested_version <= ¤t {
|
||||
if !is_portable_upgrade(requested_version, ¤t) {
|
||||
return Err(format!(
|
||||
"Portable update version {requested_version} must be newer than the current version {current}."
|
||||
));
|
||||
@@ -43,6 +43,15 @@ pub(super) fn ensure_portable_version_is_newer(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_portable_upgrade(requested: &Version, current: &Version) -> bool {
|
||||
let channel = if requested.pre.is_empty() {
|
||||
dbx_core::update::UpdateChannel::Stable
|
||||
} else {
|
||||
dbx_core::update::UpdateChannel::Beta
|
||||
};
|
||||
dbx_core::update::is_channel_update_available(channel, &requested.to_string(), ¤t.to_string())
|
||||
}
|
||||
|
||||
fn parse_portable_version(value: &str, label: &str) -> Result<Version, String> {
|
||||
let value = value.trim();
|
||||
let value = value.strip_prefix('v').unwrap_or(value);
|
||||
@@ -60,7 +69,10 @@ fn portable_arch_label(arch: &str) -> Result<&'static str, String> {
|
||||
pub(super) fn portable_asset_name(version: &str, arch: &str) -> Result<String, String> {
|
||||
let version = parse_portable_version(version, "requested")?;
|
||||
let arch = portable_arch_label(arch)?;
|
||||
Ok(format!("DBX_{version}_{arch}-portable.zip"))
|
||||
// Release workflows keep asset filenames at the stable base version even
|
||||
// when the signed executable carries a beta prerelease version.
|
||||
let base_version = format!("{}.{}.{}", version.major, version.minor, version.patch);
|
||||
Ok(format!("DBX_{base_version}_{arch}-portable.zip"))
|
||||
}
|
||||
|
||||
pub(super) fn verify_portable_archive(
|
||||
@@ -126,7 +138,7 @@ fn validated_portable_executable(
|
||||
return Err(format!("Unsupported portable update manifest schema version {}.", manifest.schema_version));
|
||||
}
|
||||
let manifest_version = parse_portable_version(&manifest.version, "manifest")?;
|
||||
if &manifest_version != expected_version {
|
||||
if !portable_versions_match(&manifest_version, expected_version) {
|
||||
return Err(format!(
|
||||
"Portable update manifest version {manifest_version} does not match requested version {expected_version}."
|
||||
));
|
||||
@@ -166,6 +178,14 @@ fn validated_portable_executable(
|
||||
Ok(executable)
|
||||
}
|
||||
|
||||
fn portable_versions_match(manifest_version: &Version, expected_version: &Version) -> bool {
|
||||
manifest_version == expected_version
|
||||
|| (expected_version.pre.is_empty()
|
||||
&& manifest_version.major == expected_version.major
|
||||
&& manifest_version.minor == expected_version.minor
|
||||
&& manifest_version.patch == expected_version.patch)
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
@@ -374,7 +394,7 @@ mod tests {
|
||||
#[test]
|
||||
fn builds_portable_asset_names_for_windows_architectures() {
|
||||
assert_eq!(portable_asset_name("0.5.64", "x86_64").unwrap(), "DBX_0.5.64_x64-portable.zip");
|
||||
assert_eq!(portable_asset_name("v0.5.64-beta.1", "aarch64").unwrap(), "DBX_0.5.64-beta.1_arm64-portable.zip");
|
||||
assert_eq!(portable_asset_name("v0.5.64-beta.1", "aarch64").unwrap(), "DBX_0.5.64_arm64-portable.zip");
|
||||
assert!(portable_asset_name("0.5.64", "x86").is_err());
|
||||
assert!(portable_asset_name("../../0.5.64", "x86_64").is_err());
|
||||
}
|
||||
@@ -428,6 +448,16 @@ mod tests {
|
||||
assert!(error.contains("must be newer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_same_base_beta_from_stable_portable_build() {
|
||||
assert!(validate_requested_portable_version("0.5.64-beta.20261029.1", "0.5.64").is_ok());
|
||||
assert!(validate_requested_portable_version("0.5.64", "0.5.64-beta.20261029.1").is_err());
|
||||
assert!(validate_requested_portable_version("0.5.65", "0.5.64-beta.20261029.1").is_ok());
|
||||
assert!(validate_requested_portable_version("0.5.63", "0.5.64-beta.20261029.1").is_err());
|
||||
assert!(validate_requested_portable_version("0.5.64-beta.20261029.2", "0.5.64-beta.20261029.1").is_ok());
|
||||
assert!(validate_requested_portable_version("0.5.64-beta.20261029.1", "0.5.64-beta.20261029.2").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_tauri_base64_text() {
|
||||
let encoded = BASE64_STANDARD.encode("untrusted comment: test\nAAAA");
|
||||
@@ -456,6 +486,15 @@ mod tests {
|
||||
assert!(error.contains("does not match requested version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_beta_binary_manifest_for_promoted_stable_base_version() {
|
||||
let archive = portable_zip("0.5.64-beta.20261029.1", "x64", b"MZbeta executable", None);
|
||||
assert_eq!(
|
||||
validated_portable_executable(&archive, &Version::parse("0.5.64").unwrap(), "x86_64").unwrap(),
|
||||
b"MZbeta executable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_executable_not_matching_the_signed_manifest_hash() {
|
||||
let archive = portable_zip("0.5.64", "x64", b"MZportable executable", Some(&"0".repeat(64)));
|
||||
|
||||
Reference in New Issue
Block a user