fix(ci): correct difyctl release and e2e workflow gates (#41355)

This commit is contained in:
Xiyuan Chen
2026-08-28 06:56:25 +00:00
committed by GitHub
parent 94fdda18e2
commit 3bc4ac44e5
17 changed files with 406 additions and 147 deletions
+30 -15
View File
@@ -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
+14 -28
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -17,6 +17,7 @@ Run package scripts from `cli/`:
- Source CLI: `pnpm dev <command> [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`.
+3 -1
View File
@@ -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
+4 -3
View File
@@ -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"
+2 -2
View File
@@ -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<ver>-<os>-<arch>). 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:-<unset>}" >&2
echo "run: pnpm build:bin" >&2
echo "run: pnpm build:bin:local" >&2
exit 1
fi
+9 -2
View File
@@ -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"
+120
View File
@@ -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<string, string | undefined> = {},
): Run {
const stubDir = tempDir('difyctl-stub-bun-')
writeFileSync(`${stubDir}/bun`, STUB_BUN)
chmodSync(`${stubDir}/bun`, 0o755)
try {
const merged: Record<string, string | undefined> = {
...process.env,
PATH: `${stubDir}:${process.env.PATH ?? ''}`,
CLI_VERSION: cliVersion,
...extraEnv,
}
const childEnv: Record<string, string> = {}
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<string, string | undefined>): 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 })
}
})
})
+24 -5
View File
@@ -14,7 +14,11 @@
// channels -> one channel name per line
// prerelease <channel> -> "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 <sha> -> <package version core>-edge.<sha>
// validate -> exit 1 if difyctl.release, version, channel, or
// difyctl.compat is malformed
// validate-version <version> <channel>
// -> exit 1 unless version matches the channel's form
// compat-check <difyVer> -> 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])
+122 -29
View File
@@ -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<string, string>): 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<string, string> {
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 <pkgcore>-edge.<sha> from the package version', () => {
expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe(
`${FIXTURE_VERSION_CORE}-edge.2fd7b82`,
)
it('edge-version derives <version core>-edge.<sha> 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)
})
})
-41
View File
@@ -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}"
+1 -1
View File
@@ -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"
-12
View File
@@ -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' }),
+3 -4
View File
@@ -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 = [
+69 -2
View File
@@ -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')
})
})
+3 -1
View File
@@ -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: {