Files
dify/cli/scripts/lib/release-rules.mjs
GareArc f1c32e1bc8 refactor(cli): split release scripts into pure rules and CLI shells
The release script tests could only observe behaviour through a process
exit code, because die() called process.exit from inside otherwise-pure
functions. That forced every test to spawn a subprocess, which forced
its input to come from cli/package.json, which is why editing that file
kept breaking tests — most recently #39658, and next the difyctl version
bump that cli-release.yml requires on every release.

Move the decisions into lib/release-rules.mjs and lib/edge-manifest.mjs,
which take every input as an argument and return values instead of
exiting. release-naming.mjs and release-r2-edge.mjs keep argv parsing,
manifest reading, stdout and exit codes, and nothing else.

Tests now split by what they ask. Logic is unit-tested against literal
inputs, the shells are tested for plumbing only, and release-config.test.ts
is the single place that reads the real manifest — asserting it is
internally consistent, never what it currently contains. Editing
cli/package.json no longer breaks a logic test; a malformed config still
fails. Verified by mutating each field in both directions.

Three intentional behaviour changes:

- compat-check compares the numeric A.B.C core only, ignoring prerelease
  and build suffixes, so Dify 1.16.0-rc1 now satisfies a 1.16.0 window.
  This matches the shipped runtime check in src/version/compat.ts, which
  already stripped suffixes; the release gate was the one disagreeing.
  Removes the hand-rolled prerelease ordering entirely.
- validate now rejects a missing or inverted compat window. It previously
  passed such a config while github-env emitted minDify=undefined.
- release-r2-edge reports a bad channel version under its own name rather
  than release-naming's, since it no longer borrows that script's die().

release-config.test.ts also pins the install scripts to the naming config.
They hardcode artifact names by necessity — they run standalone on a user
machine with no repo and no node — so changing tagPrefix or checksumsSuffix
now fails until all four installers are updated to match, instead of
silently publishing names no installer looks for.
2026-07-27 19:13:42 -07:00

116 lines
4.7 KiB
JavaScript

// Pure release naming and version rules. Nothing here reads a file, prints, or
// exits — the CLI shells own all of that.
const BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/
const SEMVER_CORE_LEN = 3
// Add channels here: { name, prerelease, versionForm }.
export const CHANNELS = [
{ name: 'stable', prerelease: false, versionForm: /^\d+\.\d+\.\d+(\+[0-9A-Z.-]+)?$/i },
{ name: 'alpha', prerelease: true, versionForm: /^\d+\.\d+\.\d+-alpha(\.\d+)?$/ },
{ name: 'rc', prerelease: true, versionForm: /^\d+\.\d+\.\d+-rc\.\d+$/ },
{ name: 'edge', prerelease: true, versionForm: /^\d+\.\d+\.\d+-edge\.[0-9a-f]{7,40}$/ },
]
export const EDGE_SHA_RE = /^[0-9a-f]{7,40}$/
export const channelByName = (name) => CHANNELS.find((c) => c.name === name)
export const channelNames = () => CHANNELS.map((c) => c.name).join(', ')
export function versionCore(v) {
return String(v).replace(/^v/, '').replace(/\+.*$/, '').split('-')[0]
}
// Compat ordering compares the numeric A.B.C core only: prerelease and build
// suffixes are stripped first, so 1.16.0-rc.1 ranks equal to 1.16.0. This
// matches the runtime check in src/version/compat.ts.
function compareCore(a, b) {
const A = versionCore(a).split('.').map(Number)
const B = versionCore(b).split('.').map(Number)
for (let i = 0; i < SEMVER_CORE_LEN; i++) {
const x = A[i] ?? 0
const y = B[i] ?? 0
if (x !== y) return x < y ? -1 : 1
}
return 0
}
export function isWithinCompatWindow(version, { minDify, maxDify }) {
return compareCore(version, minDify) >= 0 && compareCore(version, maxDify) <= 0
}
// Returns a problem string if `version` cannot be resolved under `channel`,
// else null.
export function channelVersionProblem(version, channel) {
if (typeof version !== 'string' || version.length === 0)
return 'version must be a non-empty string'
const ch = channelByName(channel)
if (!ch) return `unknown channel: ${channel} (expected one of: ${channelNames()})`
if (!ch.versionForm.test(version))
return `version ${version} does not match the ${channel} channel form`
return null
}
// Returns a problem string if the edge base cannot be derived, else null.
export function edgeVersionProblem(version, sha) {
if (!EDGE_SHA_RE.test(sha ?? '')) return 'edge-version requires a git short sha (7-40 hex chars)'
if (!/^\d+\.\d+\.\d+$/.test(versionCore(version)))
return `cannot derive edge base from version: ${version}`
return null
}
export function edgeVersionFrom(version, sha) {
return `${versionCore(version)}-edge.${sha}`
}
export function tagName(release, version) {
return `${release.tagPrefix}${version}`
}
export function checksumsName(release, version) {
return `${release.tagPrefix}${version}${release.checksumsSuffix}`
}
export function assetNameFor(release, version, id) {
const target = release.targets.find((t) => t.id === id)
if (!target) return null
return `${release.tagPrefix}${version}-${id}${target.exe ? '.exe' : ''}`
}
export function compatProblems(compat) {
const str = (v) => typeof v === 'string' && v.length > 0
const problems = []
if (!str(compat?.minDify)) problems.push('compat.minDify must be a non-empty string')
if (!str(compat?.maxDify)) problems.push('compat.maxDify must be a non-empty string')
if (problems.length === 0 && compareCore(compat.minDify, compat.maxDify) > 0)
problems.push(
`compat.minDify ${compat.minDify} must not exceed compat.maxDify ${compat.maxDify}`,
)
return problems
}
export function releaseConfigProblems(release) {
const problems = []
const str = (v) => typeof v === 'string' && v.length > 0
if (!str(release.tagPrefix)) problems.push('tagPrefix must be a non-empty string')
if (!str(release.binName)) problems.push('binName must be a non-empty string')
if (!str(release.checksumsSuffix)) problems.push('checksumsSuffix must be a non-empty string')
if (!Array.isArray(release.targets) || release.targets.length === 0) {
problems.push('targets must be a non-empty array')
return problems
}
const seen = new Set()
for (const t of release.targets) {
const label = t?.id ?? JSON.stringify(t)
if (!str(t?.id)) problems.push(`target ${label}: id must be a non-empty string`)
else if (seen.has(t.id)) problems.push(`duplicate target id: ${t.id}`)
else seen.add(t.id)
if (!str(t?.bunTarget) || !BUN_TARGET_RE.test(t.bunTarget))
problems.push(`target ${label}: bunTarget must match ${BUN_TARGET_RE}`)
if (typeof t?.exe !== 'boolean') problems.push(`target ${label}: exe must be a boolean`)
else if (str(t?.bunTarget) && t.exe !== t.bunTarget.startsWith('bun-windows-'))
problems.push(`target ${label}: exe must be true iff bunTarget is bun-windows-*`)
}
return problems
}