Files
dify/cli/scripts/release-r2-edge.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

121 lines
3.5 KiB
JavaScript

// release-r2-edge.mjs — edge/R2 release metadata generator. Two subcommands:
// manifest -> the per-channel pointer manifest.json (the installer reads this)
// index -> the per-channel build-history ledger index.json
import { existsSync, readFileSync, realpathSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import {
buildIndex,
parseChecksums,
parseDirList,
renderManifest,
resolveTargets,
} from './lib/edge-manifest.mjs'
import { channelVersionProblem } from './lib/release-rules.mjs'
import { loadPkg } from './release-naming.mjs'
class UsageError extends Error {}
// Arrow so TS infers `never`, keeping expression positions like `return die(...)` typed.
const die = (msg) => {
throw new UsageError(msg)
}
function parseArgs(argv) {
const out = {}
for (let i = 0; i < argv.length; i += 2) {
const key = argv[i]?.replace(/^--/, '')
const val = argv[i + 1]
if (!key || val === undefined || val.startsWith('--'))
die(`malformed argument near ${argv[i]} (expected --key value)`)
out[key] = val
}
return out
}
function requireArgs(args, keys) {
for (const k of keys) {
if (!args[k]) die(`missing --${k}`)
}
}
function emitManifest(args) {
requireArgs(args, ['channel', 'version', 'commit', 'build-date', 'base-url', 'checksums'])
const versionProblem = channelVersionProblem(args.version, args.channel)
if (versionProblem) die(versionProblem)
const { release, compat } = loadPkg()
const shas = parseChecksums(readFileSync(args.checksums, 'utf8'))
const { targets, missing } = resolveTargets(release, args.version, shas)
if (missing.length > 0) die(`no sha256 for ${missing[0]} in ${args.checksums}`)
return renderManifest({
binName: release.binName,
channel: args.channel,
version: args.version,
commit: args.commit,
buildDate: args['build-date'],
compat,
baseUrl: args['base-url'],
targets,
})
}
// empty / "-" / missing = no ledger yet (first publish)
function loadCurrentIndex(path) {
if (path === '-' || !existsSync(path)) return null
const raw = readFileSync(path, 'utf8').trim()
if (!raw || raw === '-') return null
try {
return JSON.parse(raw)
} catch {
return die(`current index at ${path} is not valid JSON`)
}
}
// Absent file = no reconciliation (caller could not list); empty file = no survivors.
function loadExistingDirs(path) {
if (!path || !existsSync(path)) return null
return parseDirList(readFileSync(path, 'utf8'))
}
function emitIndex(args) {
requireArgs(args, ['current', 'channel', 'version', 'commit', 'build-date'])
const index = buildIndex({
channel: args.channel,
version: args.version,
commit: args.commit,
buildDate: args['build-date'],
current: loadCurrentIndex(args.current),
existingDirs: loadExistingDirs(args['existing-dirs']),
})
return `${JSON.stringify(index, null, 2)}\n`
}
// Returns the exact bytes to write to stdout.
function main(argv) {
const [cmd, ...rest] = argv
const args = parseArgs(rest)
switch (cmd) {
case 'manifest':
return emitManifest(args)
case 'index':
return emitIndex(args)
default:
return die(`unknown subcommand: ${cmd ?? '(none)'} (expected: manifest | index)`)
}
}
const invokedDirectly =
process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
if (invokedDirectly) {
try {
process.stdout.write(main(process.argv.slice(2)))
} catch (e) {
if (!(e instanceof UsageError)) throw e
process.stderr.write(`release-r2-edge: ${e.message}\n`)
process.exit(1)
}
}
export { main }