From 31ca3f773a02fabe7f23505cce93e7346c6394f8 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 17 Sep 2026 23:51:53 +0800 Subject: [PATCH] fix(plugins): cache release builds and align scaffold workflows --- .../scripts/plugin-release-workflow.test.mjs | 127 ++++++++++++++++++ .github/workflows/ci.yml | 1 + .github/workflows/plugin-release-reusable.yml | 48 ++++++- plugins/sdk/cli/README.md | 6 + plugins/sdk/cli/src/lib.rs | 23 +++- .../common/github/plugin-release.yml | 6 +- .../frontend/github/plugin-release.yml | 8 +- plugins/sdk/cli/templates/svelte/README.md | 4 +- 8 files changed, 214 insertions(+), 9 deletions(-) create mode 100644 .github/scripts/plugin-release-workflow.test.mjs diff --git a/.github/scripts/plugin-release-workflow.test.mjs b/.github/scripts/plugin-release-workflow.test.mjs new file mode 100644 index 0000000000..d8c70ecc32 --- /dev/null +++ b/.github/scripts/plugin-release-workflow.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, 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/plugin-release-reusable.yml", import.meta.url), "utf8"); +const cacheScript = workflow.match(/^ node <<'NODE'\n([\s\S]*?)^ NODE\n/m)[1].replace(/^ {10}/gm, ""); + +function workflowStep(action) { + const start = workflow.indexOf(` - uses: ${action}\n`); + assert.notEqual(start, -1, `Missing workflow action ${action}`); + const nextStep = workflow.indexOf("\n - ", start + 1); + return workflow.slice(start, nextStep === -1 ? workflow.indexOf("\n publish:", start) : nextStep); +} + +function resolveCache(files, directory = ".") { + const root = mkdtempSync(join(tmpdir(), "dbx-plugin-release-cache-")); + try { + const project = join(root, directory); + mkdirSync(project, { recursive: true }); + for (const [name, contents] of Object.entries(files)) { + writeFileSync(join(project, name), typeof contents === "string" ? contents : JSON.stringify(contents)); + } + const output = join(root, "outputs"); + const result = spawnSync(process.execPath, ["-e", cacheScript], { + cwd: project, + env: { ...process.env, PROJECT_DIRECTORY: directory, GITHUB_OUTPUT: output }, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + return Object.fromEntries( + readFileSync(output, "utf8") + .trimEnd() + .split("\n") + .map((line) => { + const separator = line.indexOf("="); + return [line.slice(0, separator), line.slice(separator + 1)]; + }), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +test("plugins without Node dependencies do not request a dependency cache", () => { + assert.deepEqual(resolveCache({}), { manager: "", cache: "", "dependency-path": "", "pnpm-version": "" }); +}); + +test("npm caches use the package lockfile", () => { + assert.deepEqual(resolveCache({ "package.json": {}, "package-lock.json": {} }), { + manager: "npm", + cache: "npm", + "dependency-path": "package-lock.json", + "pnpm-version": "", + }); +}); + +test("npm shrinkwrap takes precedence over package-lock", () => { + const result = resolveCache({ "npm-shrinkwrap.json": {}, "package-lock.json": {} }); + assert.equal(result["dependency-path"], "npm-shrinkwrap.json"); +}); + +test("pnpm uses the packageManager version rather than overriding it", () => { + assert.deepEqual(resolveCache({ "package.json": { packageManager: "pnpm@9.15.9" }, "pnpm-lock.yaml": "" }), { + manager: "pnpm", + cache: "pnpm", + "dependency-path": "pnpm-lock.yaml", + "pnpm-version": "", + }); +}); + +test("lockfile-only pnpm projects receive an explicit bootstrap version", () => { + assert.equal(resolveCache({ "pnpm-lock.yaml": "" })["pnpm-version"], "10.27.0"); +}); + +test("declared npm projects do not accidentally use a leftover pnpm lock", () => { + const result = resolveCache({ "package.json": { packageManager: "npm@10.9.0" }, "package-lock.json": {}, "pnpm-lock.yaml": "" }); + assert.equal(result.cache, "npm"); + assert.equal(result["dependency-path"], "package-lock.json"); +}); + +test("pnpm projects without a committed lockfile can bootstrap without requesting a cache", () => { + const result = resolveCache({ "package.json": { packageManager: "pnpm@10.27.0" } }); + assert.equal(result.manager, "pnpm"); + assert.equal(result.cache, ""); + assert.equal(result["dependency-path"], ""); +}); + +test("unsupported package managers do not select the npm or pnpm cache", () => { + const result = resolveCache({ "package.json": { packageManager: "yarn@4.0.0" }, "package-lock.json": {} }); + assert.equal(result.cache, ""); +}); + +test("cache lockfile paths remain relative to the repository for nested projects", () => { + assert.equal(resolveCache({ "pnpm-lock.yaml": "" }, "plugins/example")["dependency-path"], "plugins/example/pnpm-lock.yaml"); +}); + +test("pnpm setup precedes setup-node cache restoration and respects nested package.json", () => { + const pnpmIndex = workflow.indexOf("uses: pnpm/action-setup@v4"); + const nodeIndex = workflow.indexOf("uses: actions/setup-node@v4"); + assert.ok(pnpmIndex > 0 && pnpmIndex < nodeIndex); + const pnpmStep = workflowStep("pnpm/action-setup@v4"); + assert.ok(pnpmStep.includes("package_json_file: ${{ inputs.working-directory }}/package.json")); + assert.ok(pnpmStep.includes("run_install: false")); + assert.ok(workflowStep("actions/setup-node@v4").includes("cache: ${{ steps.node-cache.outputs.cache }}")); +}); + +test("Go cache includes backend go.sum and is disabled when no checksum file exists", () => { + const goStep = workflowStep("actions/setup-go@v5"); + assert.ok(goStep.includes("cache-dependency-path: ${{ inputs.working-directory }}/**/go.sum")); + assert.ok(goStep.includes("cache: ${{ hashFiles(format('{0}/**/go.sum', inputs.working-directory)) != '' }}")); + assert.ok(goStep.includes("if: inputs.go-version != ''")); +}); + +test("source-built CLI still gets Rust when the plugin does not need it", () => { + const rustStep = workflowStep("dtolnay/rust-toolchain@stable"); + assert.ok(rustStep.includes("if: inputs.rust-toolchain != '' || (inputs.install-plugin-cli && inputs.install-plugin-cli-from-source)")); + assert.ok(rustStep.includes("toolchain: ${{ inputs.rust-toolchain || 'stable' }}")); +}); + +test("builds remain parallel and publishing still waits for every platform", () => { + assert.match(workflow, /strategy:\n\s+fail-fast: false/); + assert.doesNotMatch(workflow, /max-parallel:/); + assert.match(workflow, /publish:\n\s+name: Publish plugin release assets\n\s+needs: build/); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5eb6367d3..384c613cea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -641,6 +641,7 @@ jobs: - 'vendor/wry/**' github_scripts: - '.github/scripts/**' + - '.github/workflows/plugin-release-reusable.yml' - '.github/workflows/ci.yml' - 'apps/desktop/src/types/database.ts' - 'crates/dbx-core/assets/database-drivers.manifest.json' diff --git a/.github/workflows/plugin-release-reusable.yml b/.github/workflows/plugin-release-reusable.yml index 1f65f8ed7e..d3a352ce41 100644 --- a/.github/workflows/plugin-release-reusable.yml +++ b/.github/workflows/plugin-release-reusable.yml @@ -31,10 +31,12 @@ on: required: false type: string go-version: + description: Go version to install; leave empty for plugins without a Go backend default: "1.22.x" required: false type: string rust-toolchain: + description: Rust toolchain to install; leave empty unless the plugin or source-built CLI requires Rust default: stable required: false type: string @@ -77,17 +79,61 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Resolve Node dependency cache + id: node-cache + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + PROJECT_DIRECTORY: ${{ inputs.working-directory }} + run: | + node <<'NODE' + const fs = require("node:fs"); + const path = require("node:path"); + const packageJson = fs.existsSync("package.json") + ? JSON.parse(fs.readFileSync("package.json", "utf8")) + : {}; + const declaredManager = typeof packageJson.packageManager === "string" + ? packageJson.packageManager.split("@")[0] + : ""; + const npmLock = ["npm-shrinkwrap.json", "package-lock.json"].find((file) => fs.existsSync(file)); + const manager = declaredManager || (fs.existsSync("pnpm-lock.yaml") ? "pnpm" : npmLock ? "npm" : ""); + const lockfile = manager === "pnpm" ? "pnpm-lock.yaml" : manager === "npm" ? npmLock : ""; + const cache = lockfile && fs.existsSync(lockfile) ? manager : ""; + const outputs = { + manager, + cache, + "dependency-path": cache ? path.join(process.env.PROJECT_DIRECTORY, lockfile).replaceAll("\\", "/") : "", + "pnpm-version": manager === "pnpm" && !declaredManager ? "10.27.0" : "", + }; + for (const [name, value] of Object.entries(outputs)) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); + } + NODE + + - uses: pnpm/action-setup@v4 + if: steps.node-cache.outputs.manager == 'pnpm' + with: + version: ${{ steps.node-cache.outputs.pnpm-version }} + package_json_file: ${{ inputs.working-directory }}/package.json + run_install: false + - uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} + cache: ${{ steps.node-cache.outputs.cache }} + cache-dependency-path: ${{ steps.node-cache.outputs.dependency-path }} - uses: actions/setup-go@v5 + if: inputs.go-version != '' with: go-version: ${{ inputs.go-version }} + cache: ${{ hashFiles(format('{0}/**/go.sum', inputs.working-directory)) != '' }} + cache-dependency-path: ${{ inputs.working-directory }}/**/go.sum - uses: dtolnay/rust-toolchain@stable + if: inputs.rust-toolchain != '' || (inputs.install-plugin-cli && inputs.install-plugin-cli-from-source) with: - toolchain: ${{ inputs.rust-toolchain }} + toolchain: ${{ inputs.rust-toolchain || 'stable' }} - name: Checkout DBX plugin SDK if: inputs.install-plugin-cli && inputs.install-plugin-cli-from-source diff --git a/plugins/sdk/cli/README.md b/plugins/sdk/cli/README.md index 63d451eea7..f74c981a68 100644 --- a/plugins/sdk/cli/README.md +++ b/plugins/sdk/cli/README.md @@ -79,6 +79,12 @@ Artifact metadata always includes target, URL, SHA-256, and size. The generated Use `--sdk-root /path/to/dbx` only with Rust or Go templates while developing unpublished SDK changes from a DBX checkout. Normal npm installations use the SDK sources bundled with `@dbx-app/plugin-cli`. Generated release workflows pin the precompiled CLI version so local and CI packaging use the same SDK contract. +Generated workflows pin the reusable workflow to the same `plugin-cli-v` tag as the CLI. Go projects skip Rust setup, Rust projects skip Go setup, and frontend-only projects skip both. Source-built CLIs still install Rust even when the plugin does not need it. + +The reusable workflow restores Go module/build caches using `go.sum` files under the configured working directory, including `backend/go.sum`. npm and pnpm download caches follow the project's lockfile; pnpm uses the version declared in `package.json#packageManager`, with version 10.27.0 as the fallback for existing lockfile-only projects. Projects without a lockfile do not attempt to restore a dependency cache. Svelte projects must commit the lockfile produced by their first `npm install`; their release command uses `npm ci` and builds the frontend before packaging. + +Existing plugins pinned to an older workflow tag do not inherit these changes. Upgrade their workflow reference only after the new shared workflow has been published, keeping an immutable tag or commit rather than following `main`. A source checkout of the CLI can scaffold an unpublished workflow version; publish the matching CLI/workflow tag before using that generated release workflow. + ## Signing Official plugin authors do not create or manage signing keys. They publish unsigned candidates; DBX Store signs approved packages with the official repository key. diff --git a/plugins/sdk/cli/src/lib.rs b/plugins/sdk/cli/src/lib.rs index 5421785124..dbe2e071be 100644 --- a/plugins/sdk/cli/src/lib.rs +++ b/plugins/sdk/cli/src/lib.rs @@ -1232,6 +1232,18 @@ fn template_values( ("PUBLISHER", options.publisher.clone()), ("PUBLISHER_JSON", json_string_content(&options.publisher)), ("VERSION", options.version.clone()), + ("CLI_VERSION", CLI_VERSION.to_string()), + ("GO_VERSION", if options.template == ProjectTemplate::Go { "1.22.x" } else { "" }.to_string()), + ("RUST_TOOLCHAIN", if options.template == ProjectTemplate::Rust { "stable" } else { "" }.to_string()), + ( + "PACKAGE_COMMAND", + if options.template == ProjectTemplate::Svelte { + "npm ci && npm run build && dbx-plugin package ." + } else { + "dbx-plugin package ." + } + .to_string(), + ), ("TEMPLATE", options.template.as_str().to_string()), ("TEMPLATE_LABEL", options.template.label().to_string()), ("LANGUAGE", backend_language.map(BackendLanguage::as_str).unwrap_or("none").to_string()), @@ -1583,7 +1595,7 @@ mod tests { use super::{ color_enabled_with, create_project, generate_signing_key_file, package_manifest, package_project, resolve_create_options, run_cli, styled, title_from_slug, validate_manifest_assets, validate_semver, - BackendConfig, CreateInputs, CreateOptions, PackageOptions, ProjectTemplate, ANSI_ACCENT, + BackendConfig, CreateInputs, CreateOptions, PackageOptions, ProjectTemplate, ANSI_ACCENT, CLI_VERSION, }; #[test] @@ -1843,7 +1855,13 @@ mod tests { let workflow = std::fs::read_to_string(directory.join(".github/workflows/plugin-release.yml")).unwrap(); assert!(!workflow.contains("signing-key-id")); assert!(!workflow.contains("DBX_PLUGIN_SIGNING_KEY")); - assert!(workflow.contains("plugin-cli-version: 0.1.6")); + assert!(workflow.contains(&format!("plugin-cli-version: {CLI_VERSION}"))); + assert!(workflow.contains(&format!("plugin-release-reusable.yml@plugin-cli-v{CLI_VERSION}"))); + assert!(!workflow.contains("@plugin-sdk-v1")); + let go_version = if template == ProjectTemplate::Go { "1.22.x" } else { "" }; + let rust_toolchain = if template == ProjectTemplate::Rust { "stable" } else { "" }; + assert!(workflow.contains(&format!("go-version: \"{go_version}\""))); + assert!(workflow.contains(&format!("rust-toolchain: \"{rust_toolchain}\""))); assert!(!workflow.contains("sdk-ref:")); match template { @@ -1854,6 +1872,7 @@ mod tests { assert!(workflow.contains("\"target\":\"universal\"")); } ProjectTemplate::Svelte => { + assert!(workflow.contains("package-command: npm ci && npm run build && dbx-plugin package .")); assert!(manifest["entrypoints"].get("backend").is_none()); assert!(config.get("backend").is_none()); assert!(directory.join("src/App.svelte").is_file()); diff --git a/plugins/sdk/cli/templates/common/github/plugin-release.yml b/plugins/sdk/cli/templates/common/github/plugin-release.yml index ba07620ddd..42e0f09aad 100644 --- a/plugins/sdk/cli/templates/common/github/plugin-release.yml +++ b/plugins/sdk/cli/templates/common/github/plugin-release.yml @@ -9,10 +9,12 @@ permissions: jobs: release: - uses: t8y2/dbx/.github/workflows/plugin-release-reusable.yml@plugin-sdk-v1 + uses: t8y2/dbx/.github/workflows/plugin-release-reusable.yml@plugin-cli-v{{CLI_VERSION}} with: release-tag: ${{ github.event.release.tag_name }} package-command: dbx-plugin package . package-path: dist/*.dbxp metadata-path: dist/*.artifact.json - plugin-cli-version: 0.1.6 + plugin-cli-version: {{CLI_VERSION}} + go-version: "{{GO_VERSION}}" + rust-toolchain: "{{RUST_TOOLCHAIN}}" diff --git a/plugins/sdk/cli/templates/frontend/github/plugin-release.yml b/plugins/sdk/cli/templates/frontend/github/plugin-release.yml index ce8cb7990e..a9beea088f 100644 --- a/plugins/sdk/cli/templates/frontend/github/plugin-release.yml +++ b/plugins/sdk/cli/templates/frontend/github/plugin-release.yml @@ -9,11 +9,13 @@ permissions: jobs: release: - uses: t8y2/dbx/.github/workflows/plugin-release-reusable.yml@plugin-sdk-v1 + uses: t8y2/dbx/.github/workflows/plugin-release-reusable.yml@plugin-cli-v{{CLI_VERSION}} with: release-tag: ${{ github.event.release.tag_name }} - package-command: dbx-plugin package . + package-command: {{PACKAGE_COMMAND}} package-path: dist/*.dbxp metadata-path: dist/*.artifact.json - plugin-cli-version: 0.1.6 + plugin-cli-version: {{CLI_VERSION}} + go-version: "" + rust-toolchain: "" build-matrix: '{"include":[{"runner":"ubuntu-24.04","target":"universal"}]}' diff --git a/plugins/sdk/cli/templates/svelte/README.md b/plugins/sdk/cli/templates/svelte/README.md index d147626f1f..855eb6ae07 100644 --- a/plugins/sdk/cli/templates/svelte/README.md +++ b/plugins/sdk/cli/templates/svelte/README.md @@ -15,9 +15,11 @@ npm install dbx-plugin dev --path . --port 5190 ``` +Commit the generated `package-lock.json`. Release CI uses `npm ci` with the lockfile and caches npm downloads; it does not resolve a new dependency tree for every release. + The UI is compiled from `src/` into `ui/`. The generated `window.dbxPlugin` bridge exposes DBX context, locale, theme and backend methods to the Svelte workbench. Use the real DBX host for final integration testing. -Build an unsigned universal candidate with `dbx-plugin package .`. +Build an unsigned universal candidate with `npm run build && dbx-plugin package .`. ## Release