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

186 lines
5.6 KiB
TypeScript

import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { main as naming } from './release-naming.mjs'
import { main } from './release-r2-edge.mjs'
const VERSION = '0.1.0-edge.2fd7b82'
const BASE_URL = 'https://example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82'
const TARGET_IDS = naming(['targets'])
.trim()
.split('\n')
.map((line: string) => line.split('\t')[1])
const ASSETS = new Map(TARGET_IDS.map((id: string) => [id, naming(['asset', VERSION, id]).trim()]))
const assetFor = (id: string) => ASSETS.get(id) ?? ''
const PKG_ENV = Object.fromEntries(
naming(['github-env'])
.split('\n')
.filter(Boolean)
.map((line: string) => line.split(/=(.*)/s).slice(0, 2)),
)
type ManifestJson = {
schema: number
name: string
channel: string
version: string
commit: string
buildDate: string
compat: { minDify: string; maxDify: string }
baseUrl: string
targets: Record<string, { asset: string; sha256: string }>
}
type IndexJson = {
schema: number
channel: string
updated: string
builds: { version: string; commit: string; buildDate: string; dir: string }[]
}
function writeChecksums(ids: string[]): string {
const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-'))
const lines = ids.map((id, i) => `${String(i).repeat(64)} ${assetFor(id)}`)
const file = join(dir, 'checksums.txt')
writeFileSync(file, `${lines.join('\n')}\n`)
return file
}
function manifestArgs(version = VERSION, ids = TARGET_IDS): string[] {
return [
'manifest',
'--channel',
'edge',
'--version',
version,
'--commit',
'abc1234',
'--build-date',
'2026-06-14T12:00:00Z',
'--base-url',
BASE_URL,
'--checksums',
writeChecksums(ids),
]
}
const buildManifest = () => JSON.parse(main(manifestArgs())) as ManifestJson
describe('release-r2-edge manifest', () => {
it('passes the CLI arguments straight through to the manifest', () => {
expect(buildManifest()).toMatchObject({
schema: 1,
channel: 'edge',
version: VERSION,
commit: 'abc1234',
buildDate: '2026-06-14T12:00:00Z',
baseUrl: BASE_URL,
})
})
it('carries the compat window through from cli/package.json', () => {
expect(buildManifest().compat).toEqual({ minDify: PKG_ENV.minDify, maxDify: PKG_ENV.maxDify })
})
it('includes every target the release config declares', () => {
expect(Object.keys(buildManifest().targets).sort()).toEqual([...TARGET_IDS].sort())
})
it('rejects a version that does not match the channel form', () => {
expect(() => main(manifestArgs('0.1.0-rc.1'))).toThrow('does not match the edge channel form')
})
it('dies when a target sha is missing from the checksums file', () => {
expect(() => main(manifestArgs(VERSION, TARGET_IDS.slice(0, 1)))).toThrow('no sha256 for')
})
it('rejects a malformed dropped-value argument (no silent misparse)', () => {
// --version has no value; --commit must NOT be swallowed as the version
expect(() =>
main([
'manifest',
'--channel',
'edge',
'--version',
'--commit',
'abc1234',
'--build-date',
'2026-06-14T12:00:00Z',
'--base-url',
'https://x',
'--checksums',
'/nonexistent',
]),
).toThrow('malformed argument')
})
it('rejects an unknown subcommand', () => {
expect(() => main(['bogus'])).toThrow('unknown subcommand')
})
})
describe('release-r2-edge index', () => {
const B1 = { version: '0.1.0-edge.aaaaaaa', commit: 'aaaaaaa', buildDate: '2026-06-14T09:00:00Z' }
function indexArgs(currentContent: string | null, existingDirs?: string[]): string[] {
let currentArg = '-'
if (currentContent !== null) {
currentArg = join(mkdtempSync(join(tmpdir(), 'difyctl-index-')), 'index.json')
writeFileSync(currentArg, currentContent)
}
const extra: string[] = []
if (existingDirs !== undefined) {
const f = join(mkdtempSync(join(tmpdir(), 'difyctl-existing-')), 'existing.txt')
writeFileSync(f, `${existingDirs.join('\n')}\n`)
extra.push('--existing-dirs', f)
}
return [
'index',
'--current',
currentArg,
'--channel',
'edge',
'--version',
B1.version,
'--commit',
B1.commit,
'--build-date',
B1.buildDate,
...extra,
]
}
const buildIndex = (currentContent: string | null, existingDirs?: string[]) =>
JSON.parse(main(indexArgs(currentContent, existingDirs))) as IndexJson
it.each([
['a missing current file (arg "-")', null],
['an empty current file (first publish, curl wrote nothing)', ''],
['a "-"-content current file (curl 404 fallback)', '-\n'],
])('treats %s as a fresh ledger', (_label, content) => {
const index = buildIndex(content)
expect(index).toMatchObject({ schema: 1, channel: 'edge' })
expect(index.builds).toEqual([{ ...B1, dir: B1.version }])
})
it('dies on a current index that is not valid JSON', () => {
expect(() => main(indexArgs('{not json'))).toThrow('not valid JSON')
})
it('reads the existing-dirs file to reconcile the ledger', () => {
const older = {
version: '0.1.0-edge.bbbbbbb',
commit: 'bbbbbbb',
buildDate: '2026-06-14T08:00:00Z',
dir: '0.1.0-edge.bbbbbbb',
}
const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [older] })
expect(buildIndex(current, []).builds).toEqual([{ ...B1, dir: B1.version }])
expect(buildIndex(current, [older.dir]).builds).toEqual([{ ...B1, dir: B1.version }, older])
})
})