From 3bc4ac44e5b528d63eb0b09b7237fdbddf9544bf Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:56:25 +0000 Subject: [PATCH] fix(ci): correct difyctl release and e2e workflow gates (#41355) --- .github/workflows/cli-e2e.yml | 45 ++++-- .github/workflows/cli-release.yml | 42 ++---- .github/workflows/cli-tests.yml | 2 +- cli/AGENTS.md | 1 + cli/bin/dev.js | 4 +- cli/package.json | 7 +- cli/scripts/install-local.sh | 4 +- cli/scripts/release-build.sh | 11 +- cli/scripts/release-guards.test.ts | 120 ++++++++++++++++ cli/scripts/release-naming.mjs | 29 +++- cli/scripts/release-naming.test.ts | 151 +++++++++++++++++---- cli/scripts/release-validate-manifest.sh | 41 ------ cli/scripts/release-write-checksums.sh | 2 +- cli/src/version/render.test.ts | 12 -- cli/test/fixtures/pkg-manifest.ts | 7 +- cli/test/scripts/resolve-buildinfo.test.ts | 71 +++++++++- cli/vite.config.ts | 4 +- 17 files changed, 406 insertions(+), 147 deletions(-) create mode 100644 cli/scripts/release-guards.test.ts delete mode 100755 cli/scripts/release-validate-manifest.sh diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index 0c23cda5f58..51c5485ce0f 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -112,7 +112,7 @@ jobs: # ════════════════════════════════════════════════════════════════════════════ suite-framework-output-error: name: 'Suite: framework + output + error-handling' - if: ${{ inputs.suite_framework_output_error != 'false' }} + if: ${{ inputs.suite_framework_output_error }} needs: provision runs-on: ubuntu-latest timeout-minutes: 20 @@ -129,9 +129,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -159,7 +162,7 @@ jobs: # ════════════════════════════════════════════════════════════════════════════ suite-discovery: name: 'Suite: discovery' - if: ${{ inputs.suite_discovery != 'false' }} + if: ${{ inputs.suite_discovery }} needs: provision runs-on: ubuntu-latest timeout-minutes: 20 @@ -176,9 +179,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -207,7 +213,7 @@ jobs: # ════════════════════════════════════════════════════════════════════════════ suite-run: name: 'Suite: run / ${{ matrix.name }}' - if: ${{ inputs.suite_run != 'false' }} + if: ${{ inputs.suite_run }} needs: provision runs-on: ubuntu-latest timeout-minutes: 20 @@ -239,9 +245,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -284,7 +293,7 @@ jobs: # ════════════════════════════════════════════════════════════════════════════ suite-auth-safe: name: 'Suite: auth (login / status / whoami)' - if: ${{ inputs.suite_auth != 'false' }} + if: ${{ inputs.suite_auth }} needs: provision runs-on: ubuntu-latest timeout-minutes: 15 @@ -301,9 +310,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -333,7 +345,7 @@ jobs: suite-last: name: 'Suite: auth-use + devices + logout + agent (last, serial)' # Runs when auth is selected; also runs after all parallel jobs finish - if: ${{ inputs.suite_auth != 'false' || inputs.suite_agent != 'false' }} + if: ${{ inputs.suite_auth || inputs.suite_agent }} needs: - provision - suite-framework-output-error @@ -357,9 +369,12 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 - with: { bun-version: latest } + with: + bun-version: latest - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: { package_json_field: packageManager, run_install: false } + with: + package_json_field: packageManager + run_install: false - run: pnpm install --frozen-lockfile - run: pnpm tree:gen diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 6e0670d11e7..172ff0e8cf4 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -7,12 +7,22 @@ on: description: Dify release tag to attach difyctl assets to (blank = latest stable) required: false type: string + dry_run: + description: Build and checksum only — skip asset upload and stale-asset prune + required: false + type: boolean + default: false workflow_call: inputs: release_tag: description: Dify release tag to attach difyctl assets to (blank = latest stable) required: false type: string + dry_run: + description: Build and checksum only — skip asset upload and stale-asset prune + required: false + type: boolean + default: false release: types: [released] @@ -39,11 +49,8 @@ jobs: with: persist-credentials: false - - name: Export manifest to env - run: node scripts/release-naming.mjs github-env >> "$GITHUB_ENV" - - name: Validate manifest - run: scripts/release-validate-manifest.sh + run: node scripts/release-naming.mjs validate - name: Resolve target Dify release id: resolve @@ -75,15 +82,6 @@ jobs: DIFY_TAG: ${{ steps.resolve.outputs.dify_tag }} run: node scripts/release-naming.mjs compat-check "$DIFY_TAG" - - name: Reject duplicate difyctl version - env: - GH_TOKEN: ${{ github.token }} - run: | - if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${difyctlTag}" >/dev/null 2>&1; then - echo "::error::difyctl ${version} already released (tag ${difyctlTag} exists); bump cli/package.json version" - exit 1 - fi - release: name: build + attach standalone binaries (all targets) needs: validate @@ -120,6 +118,7 @@ jobs: - name: Compile standalone binaries (all targets) run: | + CLI_VERSION="$version" \ DIFYCTL_COMMIT="$(git rev-parse HEAD)" \ DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \ pnpm build:bin @@ -128,6 +127,7 @@ jobs: run: scripts/release-write-checksums.sh - name: Attach difyctl assets to Dify release + if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} run: | @@ -135,6 +135,7 @@ jobs: --repo "$GITHUB_REPOSITORY" --clobber - name: Prune stale difyctl assets + if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} run: | @@ -149,18 +150,3 @@ jobs: --repo "$GITHUB_REPOSITORY" --yes fi done - - - name: Create provenance tag - env: - GH_TOKEN: ${{ github.token }} - run: | - ref="refs/tags/${difyctlTag}" - sha="$(git rev-parse HEAD)" - status="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/git/refs" \ - -f ref="$ref" -f sha="$sha" --silent --include 2>/dev/null \ - | awk 'NR==1 {print $2; exit}' || true)" - case "$status" in - 201) echo "::notice::created ${ref}" ;; - 422) echo "::notice::tag ${ref} already exists; skipping (immutable)" ;; - *) echo "::error::provenance tag ${ref} not created (HTTP ${status:-unknown})"; exit 1 ;; - esac diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index 39fb7647177..9a3c59babc8 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -44,7 +44,7 @@ jobs: - name: Validate release manifest if: matrix.os == 'depot-ubuntu-24.04' - run: scripts/release-validate-manifest.sh + run: node scripts/release-naming.mjs validate - name: CI pipeline (tree, coverage, build) run: pnpm run ci diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 446c85abdcc..2b579f401ff 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -17,6 +17,7 @@ Run package scripts from `cli/`: - Source CLI: `pnpm dev [args...]` - Tests: `pnpm test` - Build: `pnpm build` +- Build a local binary: `pnpm build:bin:local` (pins `DIFYCTL_CHANNEL=dev` so it does not self-report the release channel) - Regenerate and verify the registry: `pnpm tree:gen` and `pnpm tree:check` Run the scoped static check from the repository root with `vp check cli`. diff --git a/cli/bin/dev.js b/cli/bin/dev.js index c0a1f3b9775..0a071a1764e 100755 --- a/cli/bin/dev.js +++ b/cli/bin/dev.js @@ -2,7 +2,9 @@ import { resolveBuildInfo } from '../scripts/lib/resolve-buildinfo.ts' -const info = resolveBuildInfo() +const info = resolveBuildInfo({ + env: { ...process.env, DIFYCTL_CHANNEL: process.env.DIFYCTL_CHANNEL ?? 'dev' }, +}) globalThis.__DIFYCTL_VERSION__ = info.version globalThis.__DIFYCTL_COMMIT__ = info.commit globalThis.__DIFYCTL_BUILD_DATE__ = info.buildDate diff --git a/cli/package.json b/cli/package.json index 6e0a20bfb1c..1c49f13137a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@langgenius/difyctl", - "version": "0.2.0-alpha", + "version": "1.17.0", "description": "Dify command-line interface", "license": "Apache-2.0", "files": [ @@ -32,7 +32,8 @@ "ci": "pnpm tree:check && pnpm test:coverage && pnpm build", "clean": "rm -rf dist node_modules/.cache", "version:info": "bun scripts/print-buildinfo.ts", - "build:bin": "scripts/release-build.sh" + "build:bin": "scripts/release-build.sh", + "build:bin:local": "DIFYCTL_CHANNEL=dev scripts/release-build.sh" }, "dependencies": { "@dify/contracts": "workspace:*", @@ -68,7 +69,7 @@ "node": "^22.22.1" }, "difyctl": { - "channel": "alpha", + "channel": "stable", "compat": { "minDify": "1.16.0", "maxDify": "1.17.0" diff --git a/cli/scripts/install-local.sh b/cli/scripts/install-local.sh index 892bddf9d90..2add943cd4c 100755 --- a/cli/scripts/install-local.sh +++ b/cli/scripts/install-local.sh @@ -1,6 +1,6 @@ #!/bin/sh # install-local.sh — install difyctl from locally built standalone binaries. -# Run via: pnpm install:local (after `pnpm build:bin`) +# Run via: pnpm install:local (after `pnpm build:bin:local`) # # Consumes the raw, self-contained binaries emitted by scripts/release-build.sh # into dist/bin (difyctl-v--). No GitHub Release needed: build on @@ -30,7 +30,7 @@ BINARY="$(ls "${ARTIFACT_DIR}"/difyctl-v*-${os}-${arch} 2>/dev/null | sort -V | if [ -z "$BINARY" ]; then echo "no binary found for ${os}-${arch} in ${ARTIFACT_DIR:-}" >&2 - echo "run: pnpm build:bin" >&2 + echo "run: pnpm build:bin:local" >&2 exit 1 fi diff --git a/cli/scripts/release-build.sh b/cli/scripts/release-build.sh index b7f5f52792b..16dd9a89081 100755 --- a/cli/scripts/release-build.sh +++ b/cli/scripts/release-build.sh @@ -14,8 +14,8 @@ # Env (all optional; defaults derived from cli/package.json + git): # CLI_VERSION — package.json `version` # DIFYCTL_CHANNEL — package.json `difyctl.channel` -# DIFYCTL_MIN_DIFY — package.json `difyctl.compat.minDify` -# DIFYCTL_MAX_DIFY — package.json `difyctl.compat.maxDify` +# DIFYCTL_MIN_DIFY — package.json `difyctl.compat.minDify`; must be X.Y.Z +# DIFYCTL_MAX_DIFY — package.json `difyctl.compat.maxDify`; must be X.Y.Z # DIFYCTL_COMMIT — `git rev-parse HEAD` (or "unknown") # DIFYCTL_BUILD_DATE — current UTC time # @@ -35,6 +35,10 @@ out_dir="${cli_root}/dist/bin" read_pkg() { node -p "require('${cli_root}/package.json').$1" 2>/dev/null; } naming() { node "${_dir}/release-naming.mjs" "$@"; } +require_bound() { + [[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || die "$1 must be a plain X.Y.Z version, got '$2'" +} CLI_VERSION="${CLI_VERSION:-$(read_pkg version)}" DIFYCTL_CHANNEL="${DIFYCTL_CHANNEL:-$(read_pkg difyctl.channel)}" @@ -43,6 +47,9 @@ DIFYCTL_MAX_DIFY="${DIFYCTL_MAX_DIFY:-$(read_pkg difyctl.compat.maxDify)}" DIFYCTL_COMMIT="${DIFYCTL_COMMIT:-$(git -C "$cli_root" rev-parse HEAD 2>/dev/null || echo unknown)}" DIFYCTL_BUILD_DATE="${DIFYCTL_BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" +require_bound DIFYCTL_MIN_DIFY "$DIFYCTL_MIN_DIFY" +require_bound DIFYCTL_MAX_DIFY "$DIFYCTL_MAX_DIFY" + [[ "$CLI_VERSION" != "undefined" ]] || die "CLI_VERSION could not be derived from package.json" [[ -f "$entry" ]] || die "entry not found: $entry" diff --git a/cli/scripts/release-guards.test.ts b/cli/scripts/release-guards.test.ts new file mode 100644 index 00000000000..a6fea0f9e45 --- /dev/null +++ b/cli/scripts/release-guards.test.ts @@ -0,0 +1,120 @@ +import { spawnSync } from 'node:child_process' +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vite-plus/test' + +const posix = (p: string) => p.replace(/\\/g, '/') + +const SCRIPTS_DIR = posix(fileURLToPath(new URL('.', import.meta.url))).replace(/\/$/, '') + +const BUILD_SH = 'release-build.sh' + +type Run = { code: number; stderr: string } + +const STUB_BUN = ['#!/bin/sh', 'echo "release-guards: bun must not run" >&2', 'exit 90', ''].join( + '\n', +) + +const FAKE_MANIFEST = { + version: '7.7.7', + difyctl: { + channel: 'stable', + compat: { minDify: '2.0.0', maxDify: '2.5.0' }, + release: { + tagPrefix: 'difyctl-v', + binName: 'difyctl', + checksumsSuffix: '-checksums.txt', + targets: [{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: false }], + }, + }, +} + +function tempDir(prefix: string): string { + return posix(mkdtempSync(join(tmpdir(), prefix))) +} + +function runScript( + script: string, + cliVersion?: string, + extraEnv: Record = {}, +): Run { + const stubDir = tempDir('difyctl-stub-bun-') + writeFileSync(`${stubDir}/bun`, STUB_BUN) + chmodSync(`${stubDir}/bun`, 0o755) + try { + const merged: Record = { + ...process.env, + PATH: `${stubDir}:${process.env.PATH ?? ''}`, + CLI_VERSION: cliVersion, + ...extraEnv, + } + const childEnv: Record = {} + for (const [key, value] of Object.entries(merged)) { + if (value !== undefined) childEnv[key] = value + } + const r = spawnSync('bash', [script], { encoding: 'utf8', env: childEnv }) + return { code: r.status ?? 1, stderr: r.stderr ?? '' } + } finally { + rmSync(stubDir, { recursive: true, force: true }) + } +} + +function fakeCliRoot(scriptName: string): string { + const root = tempDir('difyctl-release-guard-') + mkdirSync(`${root}/scripts/lib`, { recursive: true }) + cpSync(`${SCRIPTS_DIR}/${scriptName}`, `${root}/scripts/${scriptName}`) + cpSync(`${SCRIPTS_DIR}/lib/common.sh`, `${root}/scripts/lib/common.sh`) + cpSync(`${SCRIPTS_DIR}/release-naming.mjs`, `${root}/scripts/release-naming.mjs`) + writeFileSync(`${root}/package.json`, JSON.stringify(FAKE_MANIFEST)) + return root +} + +// Always against a throwaway root: a valid bound carries the script through to +// `rm -rf "$out_dir"`, which against the real cli root deletes a developer's build. +function runInFakeRoot(extraEnv: Record): Run { + const root = fakeCliRoot(BUILD_SH) + try { + return runScript(`${root}/scripts/${BUILD_SH}`, '2.4.0', extraEnv) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +describe.skipIf(process.platform === 'win32')('release-build.sh compat bounds', () => { + for (const bound of ['DIFYCTL_MIN_DIFY', 'DIFYCTL_MAX_DIFY']) { + it.each(['undefined', '1.16'])(`rejects ${bound}=%s`, (bad) => { + const r = runInFakeRoot({ [bound]: bad }) + expect(r.code).not.toBe(0) + expect(r.stderr).toContain(bound) + }) + } + + it('does not wipe dist/bin when a bound guard fires', () => { + const root = fakeCliRoot(BUILD_SH) + const sentinel = `${root}/dist/bin/prior-build` + try { + mkdirSync(`${root}/dist/bin`, { recursive: true }) + writeFileSync(sentinel, 'output from an earlier build') + mkdirSync(`${root}/bin`, { recursive: true }) + writeFileSync(`${root}/bin/run.ts`, 'export {}\n') + + const r = runScript(`${root}/scripts/${BUILD_SH}`, '2.4.0', { + DIFYCTL_MIN_DIFY: 'undefined', + }) + expect(r.code).not.toBe(0) + expect(existsSync(sentinel)).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/cli/scripts/release-naming.mjs b/cli/scripts/release-naming.mjs index d9fcc75c441..2af839b1f2e 100644 --- a/cli/scripts/release-naming.mjs +++ b/cli/scripts/release-naming.mjs @@ -14,7 +14,11 @@ // channels -> one channel name per line // prerelease -> "true" | "false" // github-env -> key=value lines (all fields CI needs) for $GITHUB_ENV -// validate -> exit 1 if difyctl.release, version, or channel is malformed +// edge-version -> -edge. +// validate -> exit 1 if difyctl.release, version, channel, or +// difyctl.compat is malformed +// validate-version +// -> exit 1 unless version matches the channel's form // compat-check -> exit 1 if difyVer outside compat.minDify..maxDify import { readFileSync, realpathSync } from 'node:fs' @@ -22,6 +26,8 @@ import { fileURLToPath } from 'node:url' const BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/ const SEMVER_CORE_LEN = 3 +const SEMVER_CORE_RE = /^\d+\.\d+\.\d+$/ +const COMPAT_BOUNDS = ['minDify', 'maxDify'] // Add channels here: { name, prerelease, versionForm }. const CHANNELS = [ @@ -51,7 +57,7 @@ function edgeVersion(sha) { die('edge-version requires a git short sha (7-40 hex chars)') const { version } = loadPkg() const core = versionCore(version) - if (!/^\d+\.\d+\.\d+$/.test(core)) die(`cannot derive edge base from version: ${version}`) + if (!SEMVER_CORE_RE.test(core)) die(`cannot derive edge base from version: ${version}`) return `${core}-edge.${sha}` } @@ -191,6 +197,15 @@ function validateVersionChannel(version, channel) { return problem ? [problem] : [] } +function validateCompat(compat) { + const problems = COMPAT_BOUNDS.filter((b) => !SEMVER_CORE_RE.test(compat[b] ?? '')).map( + (b) => `difyctl.compat.${b} must be a plain X.Y.Z version, found ${compat[b] ?? '(missing)'}`, + ) + if (problems.length === 0 && comparePrecedence(compat.minDify, compat.maxDify) > 0) + problems.push(`difyctl.compat.minDify (${compat.minDify}) is above maxDify (${compat.maxDify})`) + return problems +} + function main(argv) { const [cmd, ...rest] = argv switch (cmd) { @@ -236,11 +251,15 @@ function main(argv) { return String(ch.prerelease) } case 'validate': { - const { version, channel, release } = loadPkg() - const problems = [...validateRelease(release), ...validateVersionChannel(version, channel)] + const { version, channel, compat, release } = loadPkg() + const problems = [ + ...validateRelease(release), + ...validateCompat(compat), + ...validateVersionChannel(version, channel), + ] if (problems.length > 0) die(`invalid difyctl release config:\n - ${problems.join('\n - ')}`) - return `difyctl release valid: version=${version} channel=${channel} targets=${release.targets.length}` + return `difyctl release valid: version=${version} channel=${channel} compat=${compat.minDify}..${compat.maxDify} targets=${release.targets.length}` } case 'edge-version': return edgeVersion(rest[0]) diff --git a/cli/scripts/release-naming.test.ts b/cli/scripts/release-naming.test.ts index 306cf83f809..547fe55f1e4 100644 --- a/cli/scripts/release-naming.test.ts +++ b/cli/scripts/release-naming.test.ts @@ -1,4 +1,6 @@ +import type { PkgManifestOverrides } from '../test/fixtures/pkg-manifest' import { execFileSync } from 'node:child_process' +import { readFileSync, writeFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vite-plus/test' import { @@ -6,7 +8,6 @@ import { FIXTURE_COMPAT, FIXTURE_TAG_PREFIX, FIXTURE_VERSION, - FIXTURE_VERSION_CORE, pkgManifestEnv, } from '../test/fixtures/pkg-manifest' @@ -14,11 +15,13 @@ const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url)) const PKG_ENV = pkgManifestEnv() -function run(args: string[]): { code: number; stdout: string; stderr: string } { +type RunResult = { code: number; stdout: string; stderr: string } + +function exec(args: string[], pkgEnv: Record): RunResult { try { const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8', - env: { ...process.env, ...PKG_ENV }, + env: { ...process.env, ...pkgEnv }, }) return { code: 0, stdout, stderr: '' } } catch (e) { @@ -27,6 +30,38 @@ function run(args: string[]): { code: number; stdout: string; stderr: string } { } } +function run(args: string[]): RunResult { + return exec(args, PKG_ENV) +} + +function runWith(overrides: PkgManifestOverrides, args: string[]): RunResult { + return exec(args, pkgManifestEnv(overrides)) +} + +type FixtureManifest = { + version?: string + difyctl: { channel?: string; compat: { minDify?: string; maxDify?: string } } +} + +function runOnManifest(mutate: (manifest: FixtureManifest) => void, args: string[]): RunResult { + const pkgEnv = pkgManifestEnv() + const [pkgPath] = Object.values(pkgEnv) + if (!pkgPath) throw new Error('pkgManifestEnv returned no manifest path') + const manifest = JSON.parse(readFileSync(pkgPath, 'utf8')) as FixtureManifest + mutate(manifest) + writeFileSync(pkgPath, JSON.stringify(manifest)) + return exec(args, pkgEnv) +} + +function parseKeyValues(stdout: string): Record { + return Object.fromEntries( + stdout + .split('\n') + .filter(Boolean) + .map((line) => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)]), + ) +} + describe('release-naming compat-check', () => { const { minDify, maxDify } = FIXTURE_COMPAT // 2.0.0 .. 2.5.0 const compatCheck = (difyVersion?: string) => @@ -64,10 +99,6 @@ describe('release-naming compat-check', () => { expect(compatCheck(`${maxDify}+build123`)).toBe(0) }) - it('ignores build metadata when out of range', () => { - expect(compatCheck('2.5.1+build123')).not.toBe(0) - }) - it('requires a version argument', () => { expect(compatCheck()).not.toBe(0) }) @@ -75,16 +106,11 @@ describe('release-naming compat-check', () => { describe('release-naming github-env', () => { it('emits every manifest field for $GITHUB_ENV, plus a composed difyctlTag', () => { - const fields = Object.fromEntries( - run(['github-env']) - .stdout.split('\n') - .filter(Boolean) - .map((line) => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)]), - ) + const fields = parseKeyValues(run(['github-env']).stdout) expect(fields).toEqual({ version: FIXTURE_VERSION, channel: FIXTURE_CHANNEL, - prerelease: 'true', + prerelease: 'false', minDify: FIXTURE_COMPAT.minDify, maxDify: FIXTURE_COMPAT.maxDify, tagPrefix: FIXTURE_TAG_PREFIX, @@ -94,34 +120,101 @@ describe('release-naming github-env', () => { }) describe('release-naming edge channel', () => { - it('lists edge among channels', () => { - expect(run(['channels']).stdout).toMatch(/^edge$/m) - }) - - it('edge-version derives -edge. from the package version', () => { - expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe( - `${FIXTURE_VERSION_CORE}-edge.2fd7b82`, - ) + it('edge-version derives -edge. from the package version', () => { + expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe(`${FIXTURE_VERSION}-edge.2fd7b82`) }) it('edge-version accepts a 40-char sha', () => { const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000' - expect(run(['edge-version', sha]).stdout.trim()).toBe(`${FIXTURE_VERSION_CORE}-edge.${sha}`) + expect(run(['edge-version', sha]).stdout.trim()).toBe(`${FIXTURE_VERSION}-edge.${sha}`) }) it('edge-version rejects a non-hex sha', () => { expect(run(['edge-version', 'nothex!']).code).not.toBe(0) }) - it('edge-version requires a sha argument', () => { - expect(run(['edge-version']).code).not.toBe(0) - }) - - it('the edge version form matches a computed edge version', () => { - expect(run(['validate-version', '0.1.0-edge.2fd7b82', 'edge']).code).toBe(0) + it('edge-version fails when the manifest carries no version', () => { + const { code, stderr } = runOnManifest( + (m) => { + delete m.version + }, + ['edge-version', '2fd7b82'], + ) + expect(code).not.toBe(0) + expect(stderr).toContain('cannot derive edge base from version') }) it('validate-version rejects an rc string under the edge channel', () => { expect(run(['validate-version', '0.1.0-rc.1', 'edge']).code).not.toBe(0) }) }) + +describe('release-naming validate channel', () => { + const validateChannel = (channel: string) => runWith({ channel }, ['validate']) + + it.each<[string, string]>([ + ['stable', FIXTURE_VERSION], + ['alpha', `${FIXTURE_VERSION}-alpha`], + ['rc', `${FIXTURE_VERSION}-rc.1`], + ['edge', `${FIXTURE_VERSION}-edge.2fd7b82`], + ])('accepts the %s channel with a version in its form', (channel, version) => { + expect(runWith({ channel, version }, ['validate']).code).toBe(0) + }) + + it('rejects a typo of a real channel and names it', () => { + const { code, stderr } = validateChannel('stabel') + expect(code).not.toBe(0) + expect(stderr).toContain('unknown channel: stabel') + }) + + it('rejects a manifest with no channel at all', () => { + const { code, stderr } = runOnManifest( + (m) => { + delete m.difyctl.channel + }, + ['validate'], + ) + expect(code).not.toBe(0) + expect(stderr).toContain('unknown channel') + }) +}) + +describe('release-naming validate compat bounds', () => { + const validateCompat = (minDify: string, maxDify: string) => + runWith({ compat: { minDify, maxDify } }, ['validate']) + + it('accepts a well-formed window', () => { + expect(validateCompat('1.16.0', '1.17.0').code).toBe(0) + }) + + it('accepts equal bounds', () => { + expect(validateCompat('1.17.0', '1.17.0').code).toBe(0) + }) + + it('rejects an inverted window', () => { + const { code, stderr } = validateCompat('1.18.0', '1.17.0') + expect(code).not.toBe(0) + expect(stderr).toContain('is above maxDify') + }) + + it.each(['1.x', '1.16', '1.16.0-rc1', ''])('rejects %s as a bound', (bad) => { + expect(validateCompat(bad, '2.9.0').code).not.toBe(0) + expect(validateCompat('1.0.0', bad).code).not.toBe(0) + }) + + it('names the offending bound', () => { + expect(validateCompat('1.x', '1.17.0').stderr).toContain('difyctl.compat.minDify') + expect(validateCompat('1.16.0', '1.x').stderr).toContain('difyctl.compat.maxDify') + }) +}) + +describe('release-naming validate-version', () => { + it('accepts build metadata on a stable version', () => { + expect(run(['validate-version', '1.16.1+r2', 'stable']).code).toBe(0) + }) + + it('accepts an alpha version under the alpha channel, with or without a counter', () => { + expect(run(['validate-version', '1.16.1-alpha', 'alpha']).code).toBe(0) + expect(run(['validate-version', '1.16.1-alpha.2', 'alpha']).code).toBe(0) + }) +}) diff --git a/cli/scripts/release-validate-manifest.sh b/cli/scripts/release-validate-manifest.sh deleted file mode 100755 index 61f5325080b..00000000000 --- a/cli/scripts/release-validate-manifest.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -# scripts/release-validate-manifest.sh — validate cli/package.json release fields. - -set -euo pipefail - -_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=lib/common.sh -source "${_dir}/lib/common.sh" - -cd "$(cli::root)" - -SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' - -version=$(node -p "require('./package.json').version") -channel=$(node -p "require('./package.json').difyctl.channel") -min_dify=$(node -p "require('./package.json').difyctl.compat.minDify") -max_dify=$(node -p "require('./package.json').difyctl.compat.maxDify") - -# Version form (per channel) and channel validity are enforced by -# release-naming.mjs validate below — the single source for those rules. - -[[ "$min_dify" =~ $SEMVER_RE ]] || die "invalid difyctl.compat.minDify: ${min_dify}" -[[ "$max_dify" =~ $SEMVER_RE ]] || die "invalid difyctl.compat.maxDify: ${max_dify}" - -case "$min_dify" in *[xX*]*) die "wildcards not allowed in minDify: ${min_dify}" ;; esac -case "$max_dify" in *[xX*]*) die "wildcards not allowed in maxDify: ${max_dify}" ;; esac - -cmp=$(node -e " -const a = process.argv[1].split('-')[0].split('.').map(Number) -const b = process.argv[2].split('-')[0].split('.').map(Number) -for (let i = 0; i < 3; i++) { - if (a[i] !== b[i]) { console.log(a[i] < b[i] ? -1 : 1); process.exit(0) } -} -console.log(0) -" "$min_dify" "$max_dify") - -[[ "$cmp" -le 0 ]] || die "minDify (${min_dify}) > maxDify (${max_dify})" - -node "${_dir}/release-naming.mjs" validate >/dev/null - -log::info "manifest valid: version=${version} channel=${channel} compat=${min_dify}..${max_dify}" diff --git a/cli/scripts/release-write-checksums.sh b/cli/scripts/release-write-checksums.sh index b9e1cf6960e..1c7301a6125 100755 --- a/cli/scripts/release-write-checksums.sh +++ b/cli/scripts/release-write-checksums.sh @@ -19,7 +19,7 @@ cd "$(cli::root)/dist/bin" manifest="$(naming checksums "$CLI_VERSION")" asset_prefix="$(naming tag-prefix)${CLI_VERSION}-" -> "$manifest" +: > "$manifest" if command -v sha256sum >/dev/null 2>&1; then hash_cmd="sha256sum" diff --git a/cli/src/version/render.test.ts b/cli/src/version/render.test.ts index 46361bf85b0..4672bc4c7e1 100644 --- a/cli/src/version/render.test.ts +++ b/cli/src/version/render.test.ts @@ -69,18 +69,6 @@ describe('renderVersionText', () => { expect(text).toContain('install or wait for the stable channel') }) - it('appends warning when channel is alpha', () => { - const report: VersionReport = { - client: baseClient({ channel: 'alpha' }), - server: { endpoint: '', reachable: false }, - compat: { ...compatible(), status: 'unknown', detail: 'server probe skipped' }, - } - const text = renderVersionText(report) - - expect(text).toContain('WARNING: This build is a(n) alpha release') - expect(text).toContain('install or wait for the stable channel') - }) - it('appends warning when channel is edge', () => { const report: VersionReport = { client: baseClient({ channel: 'edge' }), diff --git a/cli/test/fixtures/pkg-manifest.ts b/cli/test/fixtures/pkg-manifest.ts index 7e65f1ae1bb..dd99cb6f02b 100644 --- a/cli/test/fixtures/pkg-manifest.ts +++ b/cli/test/fixtures/pkg-manifest.ts @@ -10,15 +10,14 @@ const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH' // release-naming.mjs and release-r2-edge.mjs read their data from // cli/package.json. Tests spawn them against this fixture instead, so -// assertions can name exact versions without tracking the live release. +// assertions can name exact windows without tracking the live release. // Deliberately far from any real Dify version, and min != max so "inside the // window" is a case distinct from either bound. export const FIXTURE_COMPAT = { minDify: '2.0.0', maxDify: '2.5.0' } -export const FIXTURE_VERSION_CORE = '7.7.7' -export const FIXTURE_VERSION = `${FIXTURE_VERSION_CORE}-alpha` -export const FIXTURE_CHANNEL = 'alpha' +export const FIXTURE_VERSION = '7.7.7' +export const FIXTURE_CHANNEL = 'stable' export const FIXTURE_TAG_PREFIX = 'difyctl-v' export const FIXTURE_TARGET_IDS = [ diff --git a/cli/test/scripts/resolve-buildinfo.test.ts b/cli/test/scripts/resolve-buildinfo.test.ts index 17e07a02cca..e80f16ffdb3 100644 --- a/cli/test/scripts/resolve-buildinfo.test.ts +++ b/cli/test/scripts/resolve-buildinfo.test.ts @@ -1,5 +1,15 @@ -import { describe, expect, it } from 'vite-plus/test' -import { resolveBuildInfo } from '../../scripts/lib/resolve-buildinfo.js' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, describe, expect, it } from 'vite-plus/test' +import { BUILD_CHANNELS, resolveBuildInfo } from '../../scripts/lib/resolve-buildinfo.js' +import { ENV_CACHE_DIR, ENV_CONFIG_DIR } from '../../src/store/dir.js' + +const CLI_ROOT = new URL('../../', import.meta.url) +const RELEASE_NAMING = fileURLToPath(new URL('scripts/release-naming.mjs', CLI_ROOT)) +const DEV_ENTRY = fileURLToPath(new URL('bin/dev.js', CLI_ROOT)) const FIXED_DATE = new Date('2026-05-09T12:00:00.000Z') const fixedNow = () => FIXED_DATE @@ -80,6 +90,16 @@ describe('resolveBuildInfo', () => { ).toThrow(/invalid DIFYCTL_CHANNEL: nightly/) }) + it('accepts alpha channel', () => { + const info = resolveBuildInfo({ + env: { DIFYCTL_CHANNEL: 'alpha' }, + git: noGit, + now: fixedNow, + pkg: noPkg, + }) + expect(info.channel).toBe('alpha') + }) + it('accepts rc channel', () => { const info = resolveBuildInfo({ env: { @@ -161,3 +181,50 @@ describe('resolveBuildInfo', () => { expect(info.channel).toBe('stable') }) }) + +function releaseNamingChannels(): string[] { + return execFileSync('node', [RELEASE_NAMING, 'channels'], { encoding: 'utf8' }) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) +} + +const sorted = (names: readonly string[]) => [...names].sort() + +describe('channel list parity', () => { + const LOCAL_ONLY_CHANNEL = 'dev' + + it('released channels are the build channels minus the local-only one', () => { + expect(sorted(releaseNamingChannels())).toStrictEqual( + sorted(BUILD_CHANNELS.filter((name) => name !== LOCAL_ONLY_CHANNEL)), + ) + }) +}) + +type ClientVersionReport = { client: { channel: string } } + +describe('bin/dev.js pins the local build channel', () => { + const ENV_CHANNEL = 'DIFYCTL_CHANNEL' + const stateDir = mkdtempSync(join(tmpdir(), 'difyctl-dev-channel-')) + afterAll(() => rmSync(stateDir, { recursive: true, force: true })) + + function reportedChannel(channelOverride?: string): string { + const env: NodeJS.ProcessEnv = { + ...process.env, + [ENV_CONFIG_DIR]: stateDir, + [ENV_CACHE_DIR]: stateDir, + } + if (channelOverride === undefined) delete env[ENV_CHANNEL] + else env[ENV_CHANNEL] = channelOverride + const stdout = execFileSync('bun', [DEV_ENTRY, 'version', '--client', '--output', 'json'], { + cwd: fileURLToPath(CLI_ROOT), + encoding: 'utf8', + env, + }) + return (JSON.parse(stdout) as ClientVersionReport).client.channel + } + + it('reports dev when the env does not set a channel', { timeout: 30_000 }, () => { + expect(reportedChannel()).toBe('dev') + }) +}) diff --git a/cli/vite.config.ts b/cli/vite.config.ts index 28ba19f5689..85b0e264cbe 100644 --- a/cli/vite.config.ts +++ b/cli/vite.config.ts @@ -2,7 +2,9 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite-plus' import { resolveBuildInfo } from './scripts/lib/resolve-buildinfo.js' -const buildInfo = resolveBuildInfo() +const buildInfo = resolveBuildInfo({ + env: { ...process.env, DIFYCTL_CHANNEL: process.env.DIFYCTL_CHANNEL ?? 'dev' }, +}) export default defineConfig({ resolve: {