From 4d67b2587d6bbcd286ceb38bbbcbfaa4f3ce0d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Sun, 1 Nov 2026 09:33:20 +0800 Subject: [PATCH] feat(update): add beta and stable release channels --- .github/scripts/daily-beta-release.test.mjs | 85 ++++ .github/scripts/prepare-channel-manifest.mjs | 37 ++ .../scripts/prepare-channel-manifest.test.mjs | 91 ++++ .github/scripts/prepare-msi-config.mjs | 51 ++ .github/scripts/prepare-msi-config.test.mjs | 47 ++ .github/scripts/prune-r2-releases.sh | 80 +++ .github/scripts/set-release-version.mjs | 51 ++ .github/scripts/set-release-version.test.mjs | 71 +++ .github/scripts/validate-release-ci.mjs | 28 ++ .github/scripts/validate-release-ci.test.mjs | 39 ++ .github/workflows/daily-beta-release.yml | 117 +++++ .github/workflows/publish-packages.yml | 471 +++++++++++++----- .github/workflows/release.yml | 278 ++++++++--- .github/workflows/rollback-update-channel.yml | 89 ++++ .github/workflows/sync-changelog.yml | 40 +- Cargo.lock | 1 + agents/drivers/duckdb/Cargo.lock | 1 + .../editor/CloudSyncSelectionDialog.vue | 2 +- .../editor/EditorSettingsDialog.vue | 25 + .../components/layout/UpdateDialog.spec.ts | 75 +++ .../src/components/layout/UpdateDialog.vue | 15 +- .../__tests__/useAppUpdater.spec.ts | 67 ++- apps/desktop/src/composables/useAppUpdater.ts | 76 ++- apps/desktop/src/i18n/locales/az.ts | 6 + apps/desktop/src/i18n/locales/en.ts | 6 + apps/desktop/src/i18n/locales/es.ts | 6 + apps/desktop/src/i18n/locales/id.ts | 6 + apps/desktop/src/i18n/locales/it.ts | 6 + apps/desktop/src/i18n/locales/ja.ts | 6 + apps/desktop/src/i18n/locales/ko.ts | 6 + apps/desktop/src/i18n/locales/pl.ts | 6 + apps/desktop/src/i18n/locales/pt-BR.ts | 6 + apps/desktop/src/i18n/locales/ru.ts | 6 + apps/desktop/src/i18n/locales/th.ts | 6 + apps/desktop/src/i18n/locales/tr.ts | 6 + apps/desktop/src/i18n/locales/zh-CN.ts | 6 + apps/desktop/src/i18n/locales/zh-TW.ts | 6 + .../windowsInstallerTemplate.spec.ts | 20 +- apps/desktop/src/lib/backend/http.ts | 6 +- apps/desktop/src/lib/backend/tauri.ts | 15 +- .../src/lib/settings/editorSettingsDraft.ts | 1 + .../src/lib/settings/settingsSearch.ts | 1 + .../src/lib/settings/settingsTransfer.ts | 2 +- .../stores/__tests__/settingsStore.spec.ts | 3 + apps/desktop/src/stores/settingsStore.ts | 21 +- crates/dbx-core/src/host/update.rs | 211 +++++++- crates/dbx-core/tests/agent_service.rs | 6 + crates/dbx-driver-agent/src/agent_service.rs | 2 +- crates/dbx-platform/Cargo.toml | 1 + crates/dbx-platform/src/version.rs | 26 + crates/dbx-web/src/routes/update.rs | 11 +- scripts/release.test.mjs | 52 ++ src-tauri/src/commands/update.rs | 276 ++++++++-- src-tauri/src/commands/update_cache.rs | 34 ++ src-tauri/src/commands/update_portable.rs | 47 +- 55 files changed, 2347 insertions(+), 309 deletions(-) create mode 100644 .github/scripts/daily-beta-release.test.mjs create mode 100644 .github/scripts/prepare-channel-manifest.mjs create mode 100644 .github/scripts/prepare-channel-manifest.test.mjs create mode 100644 .github/scripts/prepare-msi-config.mjs create mode 100644 .github/scripts/prepare-msi-config.test.mjs create mode 100755 .github/scripts/prune-r2-releases.sh create mode 100644 .github/scripts/set-release-version.mjs create mode 100644 .github/scripts/set-release-version.test.mjs create mode 100644 .github/scripts/validate-release-ci.mjs create mode 100644 .github/scripts/validate-release-ci.test.mjs create mode 100644 .github/workflows/daily-beta-release.yml create mode 100644 .github/workflows/rollback-update-channel.yml diff --git a/.github/scripts/daily-beta-release.test.mjs b/.github/scripts/daily-beta-release.test.mjs new file mode 100644 index 0000000000..1ee9db8c25 --- /dev/null +++ b/.github/scripts/daily-beta-release.test.mjs @@ -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); +}); diff --git a/.github/scripts/prepare-channel-manifest.mjs b/.github/scripts/prepare-channel-manifest.mjs new file mode 100644 index 0000000000..4dc0ed0be7 --- /dev/null +++ b/.github/scripts/prepare-channel-manifest.mjs @@ -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 [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`); diff --git a/.github/scripts/prepare-channel-manifest.test.mjs b/.github/scripts/prepare-channel-manifest.test.mjs new file mode 100644 index 0000000000..58238d0f14 --- /dev/null +++ b/.github/scripts/prepare-channel-manifest.test.mjs @@ -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); +}); diff --git a/.github/scripts/prepare-msi-config.mjs b/.github/scripts/prepare-msi-config.mjs new file mode 100644 index 0000000000..0bed2babde --- /dev/null +++ b/.github/scripts/prepare-msi-config.mjs @@ -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 "); + 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); + } +} diff --git a/.github/scripts/prepare-msi-config.test.mjs b/.github/scripts/prepare-msi-config.test.mjs new file mode 100644 index 0000000000..9ef5c81fe3 --- /dev/null +++ b/.github/scripts/prepare-msi-config.test.mjs @@ -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); +}); diff --git a/.github/scripts/prune-r2-releases.sh b/.github/scripts/prune-r2-releases.sh new file mode 100755 index 0000000000..7834199374 --- /dev/null +++ b/.github/scripts/prune-r2-releases.sh @@ -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: published non-prerelease releases (a promoted beta tag lives under +# releases/stable/ too) and also removes legacy releases// dirs. +# beta: published releases whose tag is a beta tag, whatever their prerelease +# flag, because a promoted beta keeps its releases/beta// directory. +# +# Nothing is deleted unless five releases are listed and 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 diff --git a/.github/scripts/set-release-version.mjs b/.github/scripts/set-release-version.mjs new file mode 100644 index 0000000000..c0eaad19ff --- /dev/null +++ b/.github/scripts/set-release-version.mjs @@ -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 [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}.`); diff --git a/.github/scripts/set-release-version.test.mjs b/.github/scripts/set-release-version.test.mjs new file mode 100644 index 0000000000..9a9076a66b --- /dev/null +++ b/.github/scripts/set-release-version.test.mjs @@ -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/); +}); diff --git a/.github/scripts/validate-release-ci.mjs b/.github/scripts/validate-release-ci.mjs new file mode 100644 index 0000000000..afc6cd0d7c --- /dev/null +++ b/.github/scripts/validate-release-ci.mjs @@ -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 "); + } + // 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}.`); +} diff --git a/.github/scripts/validate-release-ci.test.mjs b/.github/scripts/validate-release-ci.test.mjs new file mode 100644 index 0000000000..132f83d864 --- /dev/null +++ b/.github/scripts/validate-release-ci.test.mjs @@ -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/); +}); diff --git a/.github/workflows/daily-beta-release.yml b/.github/workflows/daily-beta-release.yml new file mode 100644 index 0000000000..c3e150db66 --- /dev/null +++ b/.github/workflows/daily-beta-release.yml @@ -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." diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 7d14aa3168..699df8eb5c 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a21fe6e858..847fc26df4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/rollback-update-channel.yml b/.github/workflows/rollback-update-channel.yml new file mode 100644 index 0000000000..42b09d0607 --- /dev/null +++ b/.github/workflows/rollback-update-channel.yml @@ -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' diff --git a/.github/workflows/sync-changelog.yml b/.github/workflows/sync-changelog.yml index f3d6a2b36f..559ea02b84 100644 --- a/.github/workflows/sync-changelog.yml +++ b/.github/workflows/sync-changelog.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index c27b3d0261..a76e09b3ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2631,6 +2631,7 @@ dependencies = [ "futures", "nix 0.31.3", "reqwest 0.12.28", + "semver", "serde", "tokio", "uuid", diff --git a/agents/drivers/duckdb/Cargo.lock b/agents/drivers/duckdb/Cargo.lock index 6c7600ebfb..174fcf1a64 100644 --- a/agents/drivers/duckdb/Cargo.lock +++ b/agents/drivers/duckdb/Cargo.lock @@ -1596,6 +1596,7 @@ dependencies = [ "nix", "reqwest", "serde", + "semver", "tokio", "uuid", "windows-sys 0.61.2", diff --git a/apps/desktop/src/components/editor/CloudSyncSelectionDialog.vue b/apps/desktop/src/components/editor/CloudSyncSelectionDialog.vue index 79230a6a6e..fb2e88dc41 100644 --- a/apps/desktop/src/components/editor/CloudSyncSelectionDialog.vue +++ b/apps/desktop/src/components/editor/CloudSyncSelectionDialog.vue @@ -68,7 +68,7 @@ const backupSettingsCategoryLabelKeys: Record 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 = { show_tray_icon: "appearance", linux_floating_frame_enabled: "appearance", diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index f42e66e56f..fccd01177b 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -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(settingsStore.editorSettings.updateDownloadSource); +const editUpdateChannel = ref(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;

{{ t("settings.updateRestartHint") }}

+
+
+ +

{{ t("settings.updateChannelDescription") }}

+
+ +
+
diff --git a/apps/desktop/src/components/layout/UpdateDialog.spec.ts b/apps/desktop/src/components/layout/UpdateDialog.spec.ts index 925f329667..0146bb6c9b 100644 --- a/apps/desktop/src/components/layout/UpdateDialog.spec.ts +++ b/apps/desktop/src/components/layout/UpdateDialog.spec.ts @@ -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); diff --git a/apps/desktop/src/components/layout/UpdateDialog.vue b/apps/desktop/src/components/layout/UpdateDialog.vue index 81d6452d8f..3f69b1176a 100644 --- a/apps/desktop/src/components/layout/UpdateDialog.vue +++ b/apps/desktop/src/components/layout/UpdateDialog.vue @@ -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(