feat(update): add beta and stable release channels

This commit is contained in:
二丫讲梵
2026-11-01 09:33:20 +08:00
committed by GitHub
parent a3add88da1
commit 4d67b2587d
55 changed files with 2347 additions and 309 deletions
@@ -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);
});
+51
View File
@@ -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);
});
+80
View File
@@ -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
+51
View File
@@ -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/);
});
+28
View File
@@ -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/);
});
+117
View File
@@ -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."
+349 -122
View File
@@ -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
View File
@@ -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'
+31 -9
View File
@@ -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
View File
@@ -2631,6 +2631,7 @@ dependencies = [
"futures",
"nix 0.31.3",
"reqwest 0.12.28",
"semver",
"serde",
"tokio",
"uuid",
+1
View File
@@ -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>
+75
View File
@@ -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");
+59 -17
View File
@@ -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,
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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(*) を自動実行し、一致する総行数を表示します。大きなクエリを高速に保つため既定はオフです。結果フッターから必要に応じて計算できます。",
+6
View File
@@ -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 프로세스 격리 모드 (실험적)",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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: "Лимит кэша метаданных в памяти",
+6
View File
@@ -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",
+6
View File
@@ -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ı",
+6
View File
@@ -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: "元数据内存缓存上限",
+6
View File
@@ -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"');
+4 -2
View File
@@ -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.");
}
+11 -4
View File
@@ -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,
+20 -1
View File
@@ -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);
+194 -17
View File
@@ -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"
);
}
}
+6
View File
@@ -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");
+1 -1
View File
@@ -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 {
+1
View File
@@ -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"] }
+26
View File
@@ -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"));
}
}
+9 -2
View File
@@ -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()))?))
}
+52
View File
@@ -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");
+233 -43
View File
@@ -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(), &current.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();
+34
View File
@@ -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(),
+43 -4
View File
@@ -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 <= &current {
if !is_portable_upgrade(requested_version, &current) {
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(), &current.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)));