mirror of
https://github.com/langgenius/dify.git
synced 2026-08-29 03:45:08 +08:00
fix(ci): correct difyctl release and e2e workflow gates (#41355)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}"
|
||||
@@ -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"
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
Vendored
+3
-4
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user