Compare commits

..
Author SHA1 Message Date
6f8ed69ee1 fix: fix mcp output_schema is optional (#39453)
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
2026-07-28 02:31:18 +00:00
Yunlu WenGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
59b879d2df chore: bump version to 1.16.1 (#39653)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 01:09:25 +00:00
25 changed files with 1681 additions and 922 deletions
+2
View File
@@ -107,6 +107,8 @@ class MCPTool(Tool):
if self.entity.output_schema and result.structuredContent:
for k, v in result.structuredContent.items():
yield self.create_variable_message(k, v)
elif result.structuredContent:
yield self.create_json_message(result.structuredContent)
def _process_text_content(self, content: TextContent) -> Generator[ToolInvokeMessage, None, None]:
"""Process text content and yield appropriate messages."""
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.16.0"
version = "1.16.1"
requires-python = "~=3.12.0"
dependencies = [
@@ -135,6 +135,19 @@ class TestMCPToolInvoke:
values = {m.message.variable_name: m.message.variable_value for m in var_msgs}
assert values == {"a": 1, "b": "x"}
def test_invoke_yields_json_when_structured_content_has_no_output_schema(self, orm_session: Session) -> None:
tool = _make_mcp_tool()
result = CallToolResult(content=[], structuredContent={"a": 1, "b": "x"})
with patch.object(tool, "invoke_remote_mcp_tool", return_value=result):
messages = list(tool._invoke(session=orm_session, user_id="test_user", tool_parameters={}))
assert len(messages) == 1
msg = messages[0]
assert msg.type == ToolInvokeMessage.MessageType.JSON
assert isinstance(msg.message, ToolInvokeMessage.JsonMessage)
assert msg.message.json_object == {"a": 1, "b": "x"}
class TestMCPToolUsageExtraction:
"""Test usage metadata extraction from MCP tool results."""
Generated
+2 -2
View File
@@ -1281,7 +1281,7 @@ wheels = [
[[package]]
name = "dify-agent"
version = "1.16.0"
version = "1.16.1"
source = { editable = "../dify-agent" }
dependencies = [
{ name = "httpx" },
@@ -1331,7 +1331,7 @@ docs = [
[[package]]
name = "dify-api"
version = "1.16.0"
version = "1.16.1"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },
+1 -1
View File
@@ -71,7 +71,7 @@
"channel": "alpha",
"compat": {
"minDify": "1.16.0",
"maxDify": "1.16.0"
"maxDify": "1.16.1"
},
"release": {
"tagPrefix": "difyctl-v",
+402
View File
@@ -0,0 +1,402 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { chmodSync, mkdtempSync, readFileSync, 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 'vitest'
const SCRIPT = fileURLToPath(new URL('./install-cli.sh', import.meta.url))
function pickAsset(target: string, releaseJson: string): string {
return execFileSync('sh', ['-c', `. "${SCRIPT}"; pick_asset "$1"`, 'sh', target], {
input: releaseJson,
encoding: 'utf8',
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' },
}).trim()
}
function assetVersion(name: string, target: string): string {
return execFileSync('sh', ['-c', `. "${SCRIPT}"; asset_version "$1" "$2"`, 'sh', name, target], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' },
}).trim()
}
// Stubs the only network primitive (fetch_json) so resolution logic runs fully
// offline. Routes by URL; release bodies come from env (TAG_<tag-with-._->_>),
// the latest release from LATEST_JSON, the listing from LIST_JSON. A missing
// fixture returns 22 to mimic `curl -f` on a 4xx.
/* oxlint-disable no-template-curly-in-string -- shell parameter expansions, not JS template literals */
const FETCH_STUB = [
'fetch_json() {',
' case "$1" in',
' *"/releases/latest") [ -n "${LATEST_JSON:-}" ] || return 22; printf "%s" "$LATEST_JSON" ;;',
' *"/releases?per_page=100") [ -n "${LIST_JSON:-}" ] || return 22; printf "%s" "$LIST_JSON" ;;',
' *"/releases/tags/"*)',
' _t=${1##*/releases/tags/};',
' _k=$(printf "TAG_%s" "$_t" | tr ".-" "__");',
' eval "_v=\\${$_k:-}";',
' [ -n "$_v" ] || return 22;',
' printf "%s" "$_v" ;;',
' *) return 22 ;;',
' esac',
'}',
].join('\n')
/* oxlint-enable no-template-curly-in-string */
function runLib(
program: string,
env: Record<string, string> = {},
): { code: number; stdout: string; stderr: string } {
const full = `. "${SCRIPT}"\n${FETCH_STUB}\n${program}`
const r = spawnSync('sh', ['-c', full], {
encoding: 'utf8',
env: {
...process.env,
DIFYCTL_INSTALL_LIB: '1',
DIFY_VERSION: '',
DIFYCTL_VERSION: '',
...env,
},
})
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
}
// Like runLib but with a caller-supplied fetch_json stub, so we can drive the
// real rate_limit_hint / maybe_ratelimit_exit / fetch_hit_ratelimit (which the
// script defines) by writing a classified reason to FETCH_ERR_FILE.
function runLibStub(
stub: string,
program: string,
env: Record<string, string> = {},
): { code: number; stderr: string } {
const full = `. "${SCRIPT}"\n${stub}\n${program}`
const r = spawnSync('sh', ['-c', full], {
encoding: 'utf8',
env: {
...process.env,
DIFYCTL_INSTALL_LIB: '1',
DIFY_VERSION: '',
DIFYCTL_VERSION: '',
...env,
},
})
return { code: r.status ?? 1, stderr: r.stderr ?? '' }
}
// A fetch_json that always fails with the given classification, mimicking the
// real one writing to FETCH_ERR_FILE from inside a command-substitution subshell.
function failStub(reason: string): string {
return `fetch_json() { printf '%s' '${reason}' > "$FETCH_ERR_FILE"; return 1; }`
}
const REL_1142 = JSON.stringify({
tag_name: '1.14.2',
assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }],
})
const REL_1150 = JSON.stringify({
tag_name: '1.15.0',
assets: [{ name: 'difyctl-v0.3.0-linux-x64' }],
})
const LIST_NEWEST_FIRST = JSON.stringify({
releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }],
})
const RELEASE = JSON.stringify({
tag_name: '1.14.2',
name: 'Dify 1.14.2',
assets: [
{ name: 'difyctl-v0.1.0-rc.1-linux-x64' },
{ name: 'difyctl-v0.2.0-linux-x64' },
{ name: 'difyctl-v0.2.0-linux-arm64' },
{ name: 'difyctl-v0.2.0-darwin-arm64' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'difyctl-v0.2.0-checksums.txt' },
{ name: 'some-other-asset.zip' },
],
})
describe('install-cli pick_asset', () => {
it('picks the highest difyctl version for a linux target', () => {
expect(pickAsset('linux-x64', RELEASE)).toBe('difyctl-v0.2.0-linux-x64')
})
it('matches the windows .exe asset', () => {
expect(pickAsset('windows-x64', RELEASE)).toBe('difyctl-v0.2.0-windows-x64.exe')
})
it('matches an arm64 target exactly (no x64 bleed-through)', () => {
expect(pickAsset('darwin-arm64', RELEASE)).toBe('difyctl-v0.2.0-darwin-arm64')
})
it('excludes the checksums asset', () => {
expect(pickAsset('linux-x64', RELEASE)).not.toContain('checksums')
})
it('yields empty when no asset matches the target', () => {
expect(pickAsset('darwin-x64', RELEASE)).toBe('')
})
it('picks the highest semver when several difyctl versions are present', () => {
const many = JSON.stringify({
assets: [
{ name: 'difyctl-v0.2.0-linux-x64' },
{ name: 'difyctl-v0.10.0-linux-x64' },
{ name: 'difyctl-v0.9.0-linux-x64' },
],
})
expect(pickAsset('linux-x64', many)).toBe('difyctl-v0.10.0-linux-x64')
})
})
describe('install-cli asset_version', () => {
it('extracts the version from a posix asset name', () => {
expect(assetVersion('difyctl-v0.2.0-linux-x64', 'linux-x64')).toBe('0.2.0')
})
it('extracts the version from a windows .exe asset name', () => {
expect(assetVersion('difyctl-v0.2.0-windows-x64.exe', 'windows-x64')).toBe('0.2.0')
})
it('extracts a prerelease version', () => {
expect(assetVersion('difyctl-v0.1.0-rc.1-linux-x64', 'linux-x64')).toBe('0.1.0-rc.1')
})
})
describe('install-cli resolve_release', () => {
it('DIFY_VERSION pins the release directly', () => {
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
DIFY_VERSION: '1.14.2',
TAG_1_14_2: REL_1142,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
it('DIFY_VERSION that does not exist dies with a clear message', () => {
const r = runLib('resolve_release linux-x64', { DIFY_VERSION: '9.9.9' })
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('Dify release 9.9.9 not found')
})
it('blank resolves to the latest stable release', () => {
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
LATEST_JSON: REL_1150,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.15.0')
})
it('blank dies when the latest query fails (no silent fallback)', () => {
const r = runLib('resolve_release linux-x64')
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('failed to query latest Dify release')
})
it('DIFYCTL_VERSION resolves to the release hosting that build', () => {
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
DIFYCTL_VERSION: '0.2.0',
LIST_JSON: LIST_NEWEST_FIRST,
TAG_1_15_0: REL_1150,
TAG_1_14_2: REL_1142,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
it('DIFYCTL_VERSION not hosted anywhere dies', () => {
const r = runLib('resolve_release linux-x64', {
DIFYCTL_VERSION: '9.9.9',
LIST_JSON: LIST_NEWEST_FIRST,
TAG_1_15_0: REL_1150,
TAG_1_14_2: REL_1142,
})
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('difyctl 9.9.9 not found on any Dify release')
})
})
describe('install-cli find_release_for_difyctl', () => {
it('returns the newest release whose assets host the wanted build', () => {
const r = runLib('find_release_for_difyctl 0.2.0 linux-x64', {
LIST_JSON: LIST_NEWEST_FIRST,
TAG_1_15_0: REL_1150,
TAG_1_14_2: REL_1142,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
it('dies (not false-negative) when the releases listing fails', () => {
const r = runLib('find_release_for_difyctl 0.2.0 linux-x64')
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('failed to query')
})
it('warns and skips a release whose fetch fails, then finds it later', () => {
const r = runLib('find_release_for_difyctl 0.2.0 linux-x64', {
LIST_JSON: LIST_NEWEST_FIRST,
TAG_1_14_2: REL_1142,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
expect(r.stderr).toContain('fetch failed for 1.15.0')
})
})
describe('install-cli rate limit', () => {
const futureReset = String(Math.floor(Date.now() / 1000) + 1800)
it('latest: reports the rate limit with reset ETA and remediation, not a generic error', () => {
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64')
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).toContain('resets in ~')
expect(r.stderr).toContain('GITHUB_TOKEN')
expect(r.stderr).not.toContain('failed to query latest')
})
it('DIFY_VERSION: rate limit wins over the misleading "not found" message', () => {
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', {
DIFY_VERSION: '1.15.0',
})
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('not found')
})
it('DIFYCTL_VERSION: rate limit surfaces from the nested subshell, not "not found"', () => {
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', {
DIFYCTL_VERSION: '0.2.0',
})
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('not found')
})
it('omits the ETA line when the reset epoch is missing', () => {
const r = runLibStub(failStub('ratelimit:'), 'resolve_release linux-x64')
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('resets in ~')
})
it('a non-rate-limit HTTP error falls back to the generic message (no false hint)', () => {
const r = runLibStub(failStub('http:500'), 'resolve_release linux-x64')
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('failed to query latest')
expect(r.stderr).not.toContain('rate limit exceeded')
})
})
// A stand-in for curl that honours the flags fetch_json passes (-D/-o/-w/-H) and
// fabricates a response per FAKE_MODE, so the tests exercise the REAL fetch_json
// (its curl invocation, header parsing, classification and token handling) rather
// than a stub. Header names it emits are lowercase, as HTTP/2 delivers them.
const FAKE_CURL = `#!/bin/sh
hdr=""; body=""
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift 2 ;;
-o) body="$2"; shift 2 ;;
-H) printf '%s\\n' "$2" >> "\${FAKE_HDR_LOG:-/dev/null}"; shift 2 ;;
-w) shift 2 ;;
*) shift ;;
esac
done
case "\${FAKE_MODE:-ok}" in
ok)
[ -n "$hdr" ] && printf 'HTTP/2 200\\r\\n\\r\\n' > "$hdr"
[ -n "$body" ] && printf '%s' "\${FAKE_BODY:-}" > "$body"
printf '200' ;;
ratelimit)
[ -n "$hdr" ] && printf 'HTTP/2 403\\r\\nx-ratelimit-remaining: 0\\r\\nx-ratelimit-reset: %s\\r\\n\\r\\n' "\${FAKE_RESET:-9999999999}" > "$hdr"
printf '403' ;;
perm403)
[ -n "$hdr" ] && printf 'HTTP/2 403\\r\\nx-ratelimit-remaining: 59\\r\\n\\r\\n' > "$hdr"
printf '403' ;;
notfound)
[ -n "$hdr" ] && printf 'HTTP/2 404\\r\\n\\r\\n' > "$hdr"
printf '404' ;;
net) exit 6 ;;
esac
`
// Drive the real fetch_json with FAKE_CURL first on PATH. Returns "OK|<body>" or
// "FAIL|<FETCH_ERR_FILE contents>", plus any -H lines the fake curl received.
function runRealFetch(
mode: string,
env: Record<string, string> = {},
): { result: string; headers: string } {
const dir = mkdtempSync(join(tmpdir(), 'difyctl-fakecurl-'))
const hdrLog = join(dir, 'hdrlog')
writeFileSync(join(dir, 'curl'), FAKE_CURL)
chmodSync(join(dir, 'curl'), 0o755)
const program =
'if body=$(fetch_json "https://api.github.com/repos/x/releases/latest"); then printf \'OK|%s\' "$body"; else printf \'FAIL|%s\' "$(cat "$FETCH_ERR_FILE" 2>/dev/null)"; fi'
const r = spawnSync('sh', ['-c', `. "${SCRIPT}"\n${program}`], {
encoding: 'utf8',
env: {
...process.env,
PATH: `${dir}:${process.env.PATH ?? ''}`,
DIFYCTL_INSTALL_LIB: '1',
DIFY_VERSION: '',
DIFYCTL_VERSION: '',
FAKE_MODE: mode,
FAKE_HDR_LOG: hdrLog,
...env,
},
})
let headers = ''
try {
headers = readFileSync(hdrLog, 'utf8')
} catch {
/* no headers logged */
}
rmSync(dir, { recursive: true, force: true })
return { result: (r.stdout ?? '').trim(), headers }
}
describe('install-cli fetch_json (real, fake curl on PATH)', () => {
it('returns the response body on 200', () => {
expect(runRealFetch('ok', { FAKE_BODY: '{"tag_name":"1.15.0"}' }).result).toBe(
'OK|{"tag_name":"1.15.0"}',
)
})
it('classifies a 403 with x-ratelimit-remaining:0 as a rate limit and captures the reset', () => {
expect(runRealFetch('ratelimit', { FAKE_RESET: '1893456000' }).result).toBe(
'FAIL|ratelimit:1893456000',
)
})
it('classifies a 403 with tokens left as a plain http error, not a rate limit', () => {
expect(runRealFetch('perm403').result).toBe('FAIL|http:403')
})
it('classifies a 404 as an http error', () => {
expect(runRealFetch('notfound').result).toBe('FAIL|http:404')
})
it('classifies a curl transport failure as a network error', () => {
expect(runRealFetch('net').result).toBe('FAIL|network')
})
it('sends an Authorization bearer header when GITHUB_TOKEN is set', () => {
expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: 'ghp_secret' }).headers).toContain(
'Authorization: Bearer ghp_secret',
)
})
it('falls back to GH_TOKEN when GITHUB_TOKEN is unset', () => {
expect(
runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: 'gho_fallback' }).headers,
).toContain('Authorization: Bearer gho_fallback')
})
it('sends no Authorization header when neither token is set', () => {
expect(
runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: '' }).headers,
).not.toContain('Authorization')
})
})
+53
View File
@@ -0,0 +1,53 @@
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const SCRIPT = fileURLToPath(new URL('./install-r2.ps1', import.meta.url))
const hasPwsh = spawnSync('pwsh', ['-v'], { encoding: 'utf8' }).status === 0
const d = hasPwsh ? describe : describe.skip
const MANIFEST = JSON.stringify({
schema: 1,
name: 'difyctl',
channel: 'edge',
version: '0.1.0-edge.2fd7b82',
baseUrl: 'https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82',
targets: {
'windows-x64': { asset: 'difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe', sha256: 'deadbeef' },
},
})
function pwsh(program: string): { code: number; stdout: string; stderr: string } {
const full = `. '${SCRIPT}'\n${program}`
const r = spawnSync('pwsh', ['-NoProfile', '-Command', full], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' },
})
return {
code: r.status ?? 1,
stdout: (r.stdout ?? '').replace(/\r\n/g, '\n').trim(),
stderr: r.stderr ?? '',
}
}
d('install-r2.ps1', () => {
it('parses a target asset + sha from the manifest', () => {
const prog =
`$m = ConvertFrom-Json @'\n${MANIFEST}\n'@\n` +
`Write-Output (Get-TargetField $m 'windows-x64' 'asset')\n` +
`Write-Output (Get-TargetField $m 'windows-x64' 'sha256')`
const { stdout } = pwsh(prog)
expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe\ndeadbeef')
})
it('errors when DIFYCTL_R2_BASE is unset', () => {
const r = spawnSync('pwsh', ['-NoProfile', '-File', SCRIPT], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_R2_BASE: '' },
})
if (hasPwsh) {
expect(r.status).not.toBe(0)
expect(r.stderr + r.stdout).toMatch(/DIFYCTL_R2_BASE/)
}
})
})
+149
View File
@@ -0,0 +1,149 @@
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const SCRIPT = fileURLToPath(new URL('./install-r2.sh', import.meta.url))
const MANIFEST = [
'{',
' "schema": 1,',
' "name": "difyctl",',
' "channel": "edge",',
' "version": "0.1.0-edge.2fd7b82",',
' "commit": "abc1234",',
' "buildDate": "2026-06-14T12:00:00Z",',
' "compat": {"minDify":"1.14.0","maxDify":"1.15.0"},',
' "baseUrl": "https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82",',
' "targets": {',
' "linux-x64": { "asset": "difyctl-v0.1.0-edge.2fd7b82-linux-x64", "sha256": "deadbeef" },',
' "darwin-arm64": { "asset": "difyctl-v0.1.0-edge.2fd7b82-darwin-arm64", "sha256": "cafef00d" }',
' }',
'}',
].join('\n')
const INDEX = [
'{',
' "schema": 1,',
' "channel": "edge",',
' "updated": "2026-06-15T00:00:00Z",',
' "builds": [',
' {',
' "version": "0.1.0-edge.ce4af868",',
' "commit": "ce4af868d653f405070fabb3be3303430cc030ad",',
' "buildDate": "2026-06-15T00:00:00Z",',
' "dir": "0.1.0-edge.ce4af868"',
' },',
' {',
' "version": "0.1.0-edge.aaaa111",',
' "commit": "aaaa111bbbbcccc000011112222333344445555",',
' "buildDate": "2026-06-14T00:00:00Z",',
' "dir": "0.1.0-edge.aaaa111"',
' }',
' ]',
'}',
].join('\n')
const CHECKSUMS = [
'deadbeef difyctl-v0.1.0-edge.ce4af868-linux-x64',
'cafef00d difyctl-v0.1.0-edge.ce4af868-darwin-arm64',
'beadc0de difyctl-v0.1.0-edge.ce4af868-windows-x64.exe',
].join('\n')
function lib(
program: string,
env: Record<string, string> = {},
): { code: number; stdout: string; stderr: string } {
const full = `. "${SCRIPT}"\n${program}`
const r = spawnSync('sh', ['-c', full], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', ...env },
})
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
}
describe('install-r2 manifest parsing', () => {
// install-r2.sh is POSIX-only; under git-bash on Windows `uname -s` is MINGW*,
// so detect_target intentionally dies (Windows installs go through install-r2.ps1).
it.skipIf(process.platform === 'win32')('detect_target maps to one of the 5 ids', () => {
const { stdout } = lib('detect_target')
expect(['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']).toContain(
stdout,
)
})
it('manifest_str reads a top-level string field', () => {
const { stdout } = lib(
`printf '%s' '${MANIFEST}' > "$tmp_m"; manifest_str "$tmp_m" channel`,
{},
)
expect(stdout).toBe('edge')
})
it('manifest_target_field extracts per-target values from a single line', () => {
const prog =
`printf '%s' '${MANIFEST}' > "$tmp_m"\n` +
'manifest_target_field "$tmp_m" darwin-arm64 asset\n' +
'manifest_target_field "$tmp_m" darwin-arm64 sha256'
const { stdout } = lib(prog)
expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-darwin-arm64\ncafef00d')
})
it('requires DIFYCTL_R2_BASE when run as the installer (not lib)', () => {
const r = spawnSync('sh', [SCRIPT], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_R2_BASE: '' },
})
expect(r.status).not.toBe(0)
expect(r.stderr).toMatch(/DIFYCTL_R2_BASE/)
})
it('sha256_check aborts on a checksum mismatch', () => {
const r = lib('f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" deadbeef')
expect(r.code).not.toBe(0)
expect(r.stderr).toMatch(/checksum mismatch/)
})
it('sha256_check passes on the correct hash', () => {
// sha256('hello') = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
const r = lib(
'f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 && echo OK',
)
expect(r.stdout).toBe('OK')
})
})
describe('install-r2 version/commit pin', () => {
it('index_resolve matches a build by exact version', () => {
const r = lib(
`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" version 0.1.0-edge.aaaa111`,
)
expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111')
})
it('index_resolve matches a build by commit prefix', () => {
const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit ce4af868`)
expect(r.stdout).toBe('0.1.0-edge.ce4af868\t0.1.0-edge.ce4af868')
})
it('index_resolve matches the full 40-char commit too', () => {
const r = lib(
`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit aaaa111bbbbcccc000011112222333344445555`,
)
expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111')
})
it('index_resolve prints nothing when no build matches', () => {
const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit ffffff`)
expect(r.stdout).toBe('')
})
it('checksums_target extracts sha and asset for a posix target', () => {
const r = lib(`printf '%s' '${CHECKSUMS}' > "$tmp_m"; checksums_target "$tmp_m" darwin-arm64`)
expect(r.stdout).toBe('cafef00d\tdifyctl-v0.1.0-edge.ce4af868-darwin-arm64')
})
it('checksums_target does not bleed x64 into arm64', () => {
const r = lib(`printf '%s' '${CHECKSUMS}' > "$tmp_m"; checksums_target "$tmp_m" linux-x64`)
expect(r.stdout).toBe('deadbeef\tdifyctl-v0.1.0-edge.ce4af868-linux-x64')
})
})
+297
View File
@@ -0,0 +1,297 @@
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const SCRIPT = fileURLToPath(new URL('./install.ps1', import.meta.url))
function hasPwsh(): boolean {
const r = spawnSync(
'pwsh',
['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'],
{
encoding: 'utf8',
},
)
return r.status === 0
}
const PWSH = hasPwsh()
const STUB = [
'function Invoke-RestMethod {',
' param([string]$Uri, $Headers)',
" if ($Uri -like '*/releases/latest') {",
" if (-not $env:HX_LATEST) { throw 'mock 404' }",
' return ($env:HX_LATEST | ConvertFrom-Json)',
' }',
" elseif ($Uri -like '*/releases?per_page=100') {",
" if (-not $env:HX_LIST) { throw 'mock 404' }",
' return ($env:HX_LIST | ConvertFrom-Json)',
' }',
" elseif ($Uri -like '*/releases/tags/*') {",
" $t = $Uri -replace '.*/releases/tags/', ''",
" $k = 'HX_TAG_' + ($t -replace '[.\\-]', '_')",
' $v = [Environment]::GetEnvironmentVariable($k)',
" if (-not $v) { throw 'mock 404' }",
' return ($v | ConvertFrom-Json)',
' }',
' throw "unexpected uri $Uri"',
'}',
].join('\n')
type Run = { code: number; stdout: string; stderr: string }
function runPwsh(body: string, env: Record<string, string> = {}): Run {
const script = `$ErrorActionPreference='Stop'\n${STUB}\n. '${SCRIPT}'\n${body}`
const r = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', script], {
encoding: 'utf8',
env: {
...process.env,
DIFYCTL_INSTALL_LIB: '1',
DIFY_VERSION: '',
DIFYCTL_VERSION: '',
LOCALAPPDATA: process.env.LOCALAPPDATA || '/tmp',
TEMP: process.env.TEMP || '/tmp',
...env,
},
})
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
}
const REL_1142 = JSON.stringify({
tag_name: '1.14.2',
assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }],
})
const REL_1150 = JSON.stringify({
tag_name: '1.15.0',
assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }],
})
const LIST_NEWEST_FIRST = JSON.stringify([
{ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }] },
{ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }] },
])
describe.skipIf(!PWSH)('install.ps1 Get-AssetSemver', () => {
it('extracts the version from a windows .exe asset name', () => {
const r = runPwsh("(Get-AssetSemver 'difyctl-v0.2.0-windows-x64.exe').Version")
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.2.0')
})
it('extracts a prerelease version and its rc number', () => {
const r = runPwsh(
'$a = Get-AssetSemver \'difyctl-v0.1.0-rc.1-windows-x64.exe\'; "$($a.Version) $($a.Rc)"',
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.1.0-rc.1 1')
})
it('rejects a non-windows asset (returns null)', () => {
const r = runPwsh(
"if ($null -eq (Get-AssetSemver 'difyctl-v0.2.0-linux-x64')) { 'NULL' } else { 'OBJ' }",
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
it('rejects a malformed core version (returns null)', () => {
const r = runPwsh(
"if ($null -eq (Get-AssetSemver 'difyctl-vx.y.z-windows-x64.exe')) { 'NULL' } else { 'OBJ' }",
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
})
describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
it('picks the highest semver among several windows builds', () => {
const rel = JSON.stringify({
assets: [
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'difyctl-v0.10.0-windows-x64.exe' },
{ name: 'difyctl-v0.9.0-windows-x64.exe' },
],
})
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.10.0')
})
it('prefers the stable release over an rc of the same core', () => {
const rel = JSON.stringify({
assets: [
{ name: 'difyctl-v0.2.0-rc.1-windows-x64.exe' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
],
})
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.2.0')
})
it('ignores checksums and non-windows assets', () => {
const rel = JSON.stringify({
assets: [
{ name: 'difyctl-v0.2.0-linux-x64' },
{ name: 'difyctl-v0.2.0-checksums.txt' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'some-other-asset.zip' },
],
})
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Name`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('difyctl-v0.2.0-windows-x64.exe')
})
it('yields null when no windows asset is present', () => {
const rel = JSON.stringify({ assets: [{ name: 'difyctl-v0.2.0-linux-x64' }] })
const r = runPwsh(
`if ($null -eq (Select-Asset ('${rel}' | ConvertFrom-Json))) { 'NULL' } else { 'OBJ' }`,
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
})
describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
it('DIFY_VERSION pins the release directly', () => {
const r = runPwsh('(Resolve-Release).tag_name', {
DIFY_VERSION: '1.14.2',
HX_TAG_1_14_2: REL_1142,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
it('DIFY_VERSION that does not exist throws a clear message', () => {
const r = runPwsh('(Resolve-Release).tag_name', { DIFY_VERSION: '9.9.9' })
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('Dify release 9.9.9 not found')
})
it('blank resolves to the latest release', () => {
const r = runPwsh('(Resolve-Release).tag_name', { HX_LATEST: REL_1150 })
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.15.0')
})
it('blank throws when the latest query fails (no silent fallback)', () => {
const r = runPwsh('(Resolve-Release).tag_name')
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('failed to query latest Dify release')
})
it('DIFYCTL_VERSION resolves to the release hosting that build', () => {
const r = runPwsh('(Resolve-Release).tag_name', {
DIFYCTL_VERSION: '0.2.0',
HX_LIST: LIST_NEWEST_FIRST,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
it('DIFYCTL_VERSION not hosted anywhere throws', () => {
const r = runPwsh('(Resolve-Release).tag_name', {
DIFYCTL_VERSION: '9.9.9',
HX_LIST: LIST_NEWEST_FIRST,
})
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('difyctl 9.9.9 not found on any Dify release')
})
})
describe.skipIf(!PWSH)('install.ps1 Find-ReleaseForDifyctl', () => {
it('returns the newest release whose assets host the wanted build', () => {
const r = runPwsh("(Find-ReleaseForDifyctl '0.2.0').tag_name", { HX_LIST: LIST_NEWEST_FIRST })
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
it('returns nothing when no release hosts the wanted build', () => {
const r = runPwsh(
"$x = Find-ReleaseForDifyctl '9.9.9'; if ($null -eq $x) { 'NULL' } else { $x.tag_name }",
{ HX_LIST: LIST_NEWEST_FIRST },
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
})
// Build a fake ErrorRecord whose Exception.Response is a real HttpResponseMessage
// carrying the given status + headers, matching what Get-RateLimitInfo inspects.
function fakeErr(status: number, headers: Record<string, string>): string {
const adds = Object.entries(headers)
.map(([k, v]) => `$resp.Headers.TryAddWithoutValidation('${k}','${v}') | Out-Null`)
.join('\n')
return [
`$resp = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]${status})`,
adds,
'$err = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = $resp } }',
].join('\n')
}
const futureReset = String(Math.floor(Date.now() / 1000) + 1800)
describe.skipIf(!PWSH)('install.ps1 rate limit', () => {
it('classifies a 403 with x-ratelimit-remaining:0 as rate-limited, returning the reset', () => {
const r =
runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': futureReset })}
$i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { $i.Reset }`)
expect(r.code).toBe(0)
expect(r.stdout).toBe(futureReset)
})
it('does not classify a 403 with remaining tokens as rate-limited', () => {
const r = runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '59' })}
$i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { 'LIMITED' }`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
it('always treats a 429 as rate-limited', () => {
const r = runPwsh(`${fakeErr(429, {})}
$i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { 'LIMITED' }`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('LIMITED')
})
it('returns null for an error without a response (e.g. a plain string throw)', () => {
const r = runPwsh(
"$err = [pscustomobject]@{ Exception = [pscustomobject]@{} }; if ($null -eq (Get-RateLimitInfo $err)) { 'NULL' } else { 'OBJ' }",
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
it('Write-RateLimitHint prints cause, ETA, and remediation to stderr', () => {
const r = runPwsh(`Write-RateLimitHint '${futureReset}'`)
expect(r.code).toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).toContain('resets in ~')
expect(r.stderr).toContain('GITHUB_TOKEN')
})
it('Write-RateLimitHint omits the ETA line when the reset epoch is missing', () => {
const r = runPwsh("Write-RateLimitHint ''")
expect(r.code).toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('resets in ~')
})
it('sends an Authorization header when GITHUB_TOKEN is set', () => {
const r = runPwsh('$headers.Authorization', { GITHUB_TOKEN: 'ghp_secret123' })
expect(r.code).toBe(0)
expect(r.stdout).toBe('Bearer ghp_secret123')
})
it('Resolve-Release surfaces the rate-limit hint and exits, not "not found"', () => {
const stub = `function Invoke-RestMethod { throw [Microsoft.PowerShell.Commands.HttpResponseException]::new('rate limited', $script:rlResp) }`
const setup = `$script:rlResp = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]403)
$script:rlResp.Headers.TryAddWithoutValidation('x-ratelimit-remaining','0') | Out-Null
$script:rlResp.Headers.TryAddWithoutValidation('x-ratelimit-reset','${futureReset}') | Out-Null`
const r = runPwsh(`${setup}\n${stub}\nResolve-Release`, { DIFY_VERSION: '1.15.0' })
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('not found')
})
})
-78
View File
@@ -1,78 +0,0 @@
import { assetNameFor } from './release-rules.mjs'
// checksums lines are "<sha256> <assetName>"
export function parseChecksums(text) {
const map = new Map()
for (const line of text.split('\n')) {
const m = line.match(/^([0-9a-f]{64})\s+(\S+)$/i)
if (m) map.set(m[2], m[1])
}
return map
}
// Newline-delimited dir names of binaries that still exist in R2.
export function parseDirList(text) {
const set = new Set()
for (const line of text.split('\n')) {
const d = line.trim()
if (d) set.add(d)
}
return set
}
export function resolveTargets(release, version, shas) {
const targets = []
const missing = []
for (const t of release.targets) {
const asset = assetNameFor(release, version, t.id)
const sha = shas.get(asset)
if (sha) targets.push({ id: t.id, asset, sha })
else missing.push(asset)
}
return { targets, missing }
}
export function renderManifest({
binName,
channel,
version,
commit,
buildDate,
compat,
baseUrl,
targets,
}) {
const head = {
schema: 1,
name: binName,
channel,
version,
commit,
buildDate,
compat: { minDify: compat.minDify, maxDify: compat.maxDify },
baseUrl,
}
const headLines = Object.entries(head)
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`)
.join(',\n')
const targetLines = targets
.map(
(t) =>
` ${JSON.stringify(t.id)}: { "asset": ${JSON.stringify(t.asset)}, "sha256": ${JSON.stringify(t.sha)} }`,
)
.join(',\n')
return `{\n${headLines},\n "targets": {\n${targetLines}\n }\n}\n`
}
// `current` is the parsed ledger or null (first publish). `existingDirs` is a
// Set of build dirs still present in R2, or null when the caller could not list
// them — lifecycle/TTL on the bin prefix is the only deletion mechanism, so the
// ledger must never advertise a build whose binary is gone. The new build is
// always kept (just uploaded). No count cap.
export function buildIndex({ channel, version, commit, buildDate, current, existingDirs }) {
const entry = { version, commit, buildDate, dir: version }
const kept = (current?.builds ?? []).filter((b) => b.version !== entry.version)
let builds = [entry, ...kept]
if (existingDirs) builds = builds.filter((b) => b.dir === entry.dir || existingDirs.has(b.dir))
return { schema: 1, channel, updated: buildDate, builds }
}
-155
View File
@@ -1,155 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildIndex,
parseChecksums,
parseDirList,
renderManifest,
resolveTargets,
} from './edge-manifest.mjs'
const RELEASE = {
tagPrefix: 'testctl-v',
binName: 'testctl',
checksumsSuffix: '.sums',
targets: [
{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: false },
{ id: 'windows-x64', bunTarget: 'bun-windows-x64', exe: true },
],
}
const VERSION = '7.7.7-edge.2fd7b82'
const SHA_LINUX = 'a'.repeat(64)
const SHA_WINDOWS = 'b'.repeat(64)
const CHECKSUMS = [
`${SHA_LINUX} testctl-v${VERSION}-linux-x64`,
`${SHA_WINDOWS} testctl-v${VERSION}-windows-x64.exe`,
].join('\n')
describe('parseChecksums', () => {
it('maps asset name to sha256', () => {
const map = parseChecksums(CHECKSUMS)
expect(map.get(`testctl-v${VERSION}-linux-x64`)).toBe(SHA_LINUX)
expect(map.get(`testctl-v${VERSION}-windows-x64.exe`)).toBe(SHA_WINDOWS)
})
it('ignores blank and malformed lines', () => {
expect(parseChecksums('\nnot a checksum line\n\n').size).toBe(0)
})
})
describe('parseDirList', () => {
it('collects non-blank trimmed lines', () => {
expect([...parseDirList(' a \n\n b\n')]).toEqual(['a', 'b'])
})
it('yields an empty set for empty input', () => {
expect(parseDirList('').size).toBe(0)
})
})
describe('resolveTargets', () => {
it('pairs every target with its sha', () => {
const { targets, missing } = resolveTargets(RELEASE, VERSION, parseChecksums(CHECKSUMS))
expect(missing).toEqual([])
expect(targets).toEqual([
{ id: 'linux-x64', asset: `testctl-v${VERSION}-linux-x64`, sha: SHA_LINUX },
{ id: 'windows-x64', asset: `testctl-v${VERSION}-windows-x64.exe`, sha: SHA_WINDOWS },
])
})
it('reports assets with no checksum', () => {
const partial = parseChecksums(`${SHA_LINUX} testctl-v${VERSION}-linux-x64`)
const { targets, missing } = resolveTargets(RELEASE, VERSION, partial)
expect(targets).toHaveLength(1)
expect(missing).toEqual([`testctl-v${VERSION}-windows-x64.exe`])
})
})
describe('renderManifest', () => {
const manifest = () =>
renderManifest({
binName: RELEASE.binName,
channel: 'edge',
version: VERSION,
commit: 'abc1234',
buildDate: '2026-06-14T12:00:00Z',
compat: { minDify: '2.0.0', maxDify: '2.5.0' },
baseUrl: 'https://example.r2.dev/testctl/edge',
targets: resolveTargets(RELEASE, VERSION, parseChecksums(CHECKSUMS)).targets,
})
it('emits the pointer fields', () => {
const json = JSON.parse(manifest())
expect(json).toMatchObject({
schema: 1,
name: 'testctl',
channel: 'edge',
version: VERSION,
commit: 'abc1234',
buildDate: '2026-06-14T12:00:00Z',
baseUrl: 'https://example.r2.dev/testctl/edge',
})
})
it('carries both compat bounds through unswapped', () => {
expect(JSON.parse(manifest()).compat).toEqual({ minDify: '2.0.0', maxDify: '2.5.0' })
})
it('lists each target with asset name and sha256', () => {
expect(JSON.parse(manifest()).targets).toEqual({
'linux-x64': { asset: `testctl-v${VERSION}-linux-x64`, sha256: SHA_LINUX },
'windows-x64': { asset: `testctl-v${VERSION}-windows-x64.exe`, sha256: SHA_WINDOWS },
})
})
it('renders each target on a single line (install-r2.sh greps it)', () => {
expect(manifest()).toMatch(/^ {4}"linux-x64": \{ "asset": ".*", "sha256": ".*" \}/m)
})
})
describe('buildIndex', () => {
const B1 = { version: '7.7.7-edge.aaaaaaa', commit: 'aaaaaaa', buildDate: '2026-06-14T09:00:00Z' }
const B2 = { version: '7.7.7-edge.bbbbbbb', commit: 'bbbbbbb', buildDate: '2026-06-14T10:00:00Z' }
const entryOf = (b: typeof B1) => ({ ...b, dir: b.version })
const build = (over = {}) =>
buildIndex({ channel: 'edge', ...B2, current: null, existingDirs: null, ...over })
it('creates a fresh ledger when there is no current index', () => {
const index = build()
expect(index).toMatchObject({ schema: 1, channel: 'edge', updated: B2.buildDate })
expect(index.builds).toEqual([entryOf(B2)])
})
it('prepends the new build, newest first', () => {
const index = build({ current: { builds: [entryOf(B1)] } })
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
})
it('replaces an existing entry for the same version rather than duplicating', () => {
const stale = { ...entryOf(B2), commit: 'oldsha' }
const index = build({ current: { builds: [stale, entryOf(B1)] } })
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
})
it('drops builds whose binaries no longer exist in R2', () => {
const index = build({
current: { builds: [entryOf(B1)] },
existingDirs: new Set<string>(),
})
expect(index.builds).toEqual([entryOf(B2)])
})
it('keeps builds that still exist in R2', () => {
const index = build({
current: { builds: [entryOf(B1)] },
existingDirs: new Set([B1.version]),
})
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
})
it('reconciles nothing when the caller could not list R2', () => {
const index = build({ current: { builds: [entryOf(B1)] }, existingDirs: null })
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
})
})
-115
View File
@@ -1,115 +0,0 @@
// 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
}
-183
View File
@@ -1,183 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
assetNameFor,
channelVersionProblem,
checksumsName,
compatProblems,
edgeVersionFrom,
edgeVersionProblem,
isWithinCompatWindow,
releaseConfigProblems,
tagName,
} from './release-rules.mjs'
const RELEASE = {
tagPrefix: 'testctl-v',
binName: 'testctl',
checksumsSuffix: '.sums',
targets: [
{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: false },
{ id: 'windows-x64', bunTarget: 'bun-windows-x64', exe: true },
],
}
const WINDOW = { minDify: '2.0.0', maxDify: '2.5.0' }
describe('isWithinCompatWindow', () => {
it.each([
['2.3.0', true, 'inside the window'],
['2.0.0', true, 'the inclusive lower bound'],
['2.5.0', true, 'the inclusive upper bound'],
['v2.3.0', true, 'a v-prefixed tag'],
['1.9.9', false, 'below the lower bound'],
['2.5.1', false, 'above the upper bound'],
])('%s -> %s (%s)', (version, expected) => {
expect(isWithinCompatWindow(version, WINDOW)).toBe(expected)
})
it.each([
['2.0.0-rc.1', true, 'a prerelease of the lower bound ranks equal to it'],
['2.5.0-rc.1', true, 'a prerelease of the upper bound ranks equal to it'],
['2.3.0-alpha.7+build9', true, 'both suffixes stripped before comparing'],
['2.5.0+build123', true, 'build metadata on the upper bound'],
['2.5.1-rc.1', false, 'a prerelease whose core is above the window'],
['1.9.9-rc.1', false, 'a prerelease whose core is below the window'],
['2.5.1+build123', false, 'build metadata out of range'],
])('ignores suffixes: %s -> %s (%s)', (version, expected) => {
expect(isWithinCompatWindow(version, WINDOW)).toBe(expected)
})
})
describe('channelVersionProblem', () => {
it.each([
['1.2.3', 'stable'],
['1.2.3+build', 'stable'],
['1.2.3-alpha', 'alpha'],
['1.2.3-alpha.4', 'alpha'],
['1.2.3-rc.4', 'rc'],
['1.2.3-edge.2fd7b82', 'edge'],
])('accepts %s on the %s channel', (version, channel) => {
expect(channelVersionProblem(version, channel)).toBeNull()
})
it.each([
['1.2.3-rc.1', 'edge'],
['1.2.3-alpha', 'stable'],
['1.2.3', 'rc'],
])('rejects %s on the %s channel', (version, channel) => {
expect(channelVersionProblem(version, channel)).toMatch(/does not match the .* channel form/)
})
it('rejects an unknown channel', () => {
expect(channelVersionProblem('1.2.3', 'nightly')).toMatch(/unknown channel/)
})
it('rejects an empty version', () => {
expect(channelVersionProblem('', 'stable')).toMatch(/non-empty string/)
})
})
describe('edge version derivation', () => {
it('derives <core>-edge.<sha>, dropping the prerelease', () => {
expect(edgeVersionProblem('3.4.5-alpha', '2fd7b82')).toBeNull()
expect(edgeVersionFrom('3.4.5-alpha', '2fd7b82')).toBe('3.4.5-edge.2fd7b82')
})
it('accepts a 40-char sha', () => {
const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000'
expect(edgeVersionProblem('3.4.5-alpha', sha)).toBeNull()
expect(edgeVersionFrom('3.4.5-alpha', sha)).toBe(`3.4.5-edge.${sha}`)
})
it.each([['nothex!'], [''], ['abc'], [undefined]])('rejects sha %s', (sha) => {
expect(edgeVersionProblem('3.4.5-alpha', sha)).toMatch(/git short sha/)
})
it('rejects a version with no derivable core', () => {
expect(edgeVersionProblem('not-a-version', '2fd7b82')).toMatch(/cannot derive edge base/)
})
})
describe('artifact names', () => {
it('builds the tag and checksums names from the prefix', () => {
expect(tagName(RELEASE, '9.9.9')).toBe('testctl-v9.9.9')
expect(checksumsName(RELEASE, '9.9.9')).toBe('testctl-v9.9.9.sums')
})
it('appends .exe only for targets flagged exe', () => {
expect(assetNameFor(RELEASE, '9.9.9', 'linux-x64')).toBe('testctl-v9.9.9-linux-x64')
expect(assetNameFor(RELEASE, '9.9.9', 'windows-x64')).toBe('testctl-v9.9.9-windows-x64.exe')
})
it('returns null for an unknown target id', () => {
expect(assetNameFor(RELEASE, '9.9.9', 'solaris-sparc')).toBeNull()
})
})
describe('compatProblems', () => {
it('accepts a well-formed window', () => {
expect(compatProblems(WINDOW)).toEqual([])
expect(compatProblems({ minDify: '2.0.0', maxDify: '2.0.0' })).toEqual([])
})
it.each([
[undefined, /minDify must be a non-empty string/],
[{ maxDify: '2.5.0' }, /minDify must be a non-empty string/],
[{ minDify: '2.0.0' }, /maxDify must be a non-empty string/],
[{ minDify: '', maxDify: '' }, /minDify must be a non-empty string/],
])('flags a missing bound', (compat, expected) => {
expect(compatProblems(compat).join('\n')).toMatch(expected)
})
it('compares bounds by core, so suffixes do not make a window inverted', () => {
expect(compatProblems({ minDify: '2.0.0-rc.1', maxDify: '2.0.0' })).toEqual([])
})
it('flags an inverted window', () => {
expect(compatProblems({ minDify: '2.5.0', maxDify: '2.0.0' }).join('\n')).toMatch(
/must not exceed/,
)
})
})
describe('releaseConfigProblems', () => {
it('accepts a well-formed config', () => {
expect(releaseConfigProblems(RELEASE)).toEqual([])
})
it.each([
[{ ...RELEASE, tagPrefix: '' }, /tagPrefix must be a non-empty string/],
[{ ...RELEASE, binName: '' }, /binName must be a non-empty string/],
[{ ...RELEASE, checksumsSuffix: '' }, /checksumsSuffix must be a non-empty string/],
[{ ...RELEASE, targets: [] }, /targets must be a non-empty array/],
[{ ...RELEASE, targets: undefined }, /targets must be a non-empty array/],
])('flags a malformed field', (release, expected) => {
expect(releaseConfigProblems(release).join('\n')).toMatch(expected)
})
it('flags a duplicate target id', () => {
const release = { ...RELEASE, targets: [RELEASE.targets[0], { ...RELEASE.targets[0] }] }
expect(releaseConfigProblems(release).join('\n')).toMatch(/duplicate target id: linux-x64/)
})
it('flags a bunTarget that is not a bun triple', () => {
const release = { ...RELEASE, targets: [{ id: 'a', bunTarget: 'linux-x64', exe: false }] }
expect(releaseConfigProblems(release).join('\n')).toMatch(/bunTarget must match/)
})
it('flags exe disagreeing with a windows bunTarget', () => {
const release = {
...RELEASE,
targets: [{ id: 'windows-x64', bunTarget: 'bun-windows-x64', exe: false }],
}
expect(releaseConfigProblems(release).join('\n')).toMatch(/exe must be true iff/)
})
it('flags a non-boolean exe', () => {
const release = {
...RELEASE,
targets: [{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: 'no' }],
}
expect(releaseConfigProblems(release).join('\n')).toMatch(/exe must be a boolean/)
})
})
-72
View File
@@ -1,72 +0,0 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { main } from './release-naming.mjs'
const env = Object.fromEntries(
main(['github-env'])
.split('\n')
.filter(Boolean)
.map((line: string) => line.split(/=(.*)/s).slice(0, 2)),
)
// Install scripts run standalone on an end user's machine — no repo, no node —
// so they hardcode artifact names instead of asking release-naming.mjs. These
// checks are what keeps the two copies from drifting: change the naming config
// and they fail until the installers are updated to match.
const INSTALLERS = ['install-cli.sh', 'install-r2.sh', 'install.ps1', 'install-r2.ps1']
const installerText = (name: string) =>
readFileSync(fileURLToPath(new URL(`./${name}`, import.meta.url)), 'utf8')
const CHECKSUMS_SUFFIX = main(['checksums', '1.2.3']).trim().replace(`${env.tagPrefix}1.2.3`, '')
const EXE_TARGET_ID = main(['targets'])
.trim()
.split('\n')
.map((line: string) => line.split('\t'))
.find(([, , exe]: string[]) => exe === '1')?.[1]
describe('install scripts stay aligned with the release naming config', () => {
it.each(INSTALLERS)('%s uses the configured tag prefix', (name) => {
expect(installerText(name)).toContain(env.tagPrefix)
})
it.each(INSTALLERS)('%s uses the configured checksums suffix', (name) => {
expect(installerText(name)).toContain(CHECKSUMS_SUFFIX)
})
it.each(['install.ps1', 'install-r2.ps1'])('%s targets the declared windows build', (name) => {
expect(EXE_TARGET_ID).toBeTruthy()
expect(installerText(name)).toContain(EXE_TARGET_ID)
})
})
describe('the shipped difyctl release config', () => {
it('passes the release gate (`release-naming.mjs validate`)', () => {
expect(main(['validate'])).toMatch(/^difyctl release valid:/)
})
it('declares a usable, correctly ordered compat window', () => {
expect(main(['compat-check', env.minDify])).toContain('compatible')
expect(main(['compat-check', env.maxDify])).toContain('compatible')
})
it('gates a Dify version outside the declared window', () => {
const aboveMax = `${Number(env.maxDify.split('.')[0]) + 1}.0.0`
expect(() => main(['compat-check', aboveMax])).toThrow('outside difyctl compatibility window')
expect(() => main(['compat-check', '0.0.1'])).toThrow('outside difyctl compatibility window')
})
it('declares a version valid for the channel it ships on', () => {
expect(main(['validate-version', env.version, env.channel])).toContain('valid')
})
it('can name an artifact for every target it declares', () => {
const ids = main(['targets'])
.trim()
.split('\n')
.map((line: string) => line.split('\t')[1])
expect(ids.length).toBeGreaterThan(0)
for (const id of ids) expect(main(['asset', env.version, id])).toContain(id)
})
})
+172 -63
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env node
// release-naming.mjs — single source of truth for difyctl release artifact
// names and version/channel rules. Reads DATA from cli/package.json
// `difyctl.release` (plus `version` and `difyctl.channel`); the name FORMAT and
// the per-channel version form live in lib/release-rules.mjs. Producer scripts
// call this; `validate` is the release gate.
// `difyctl.release` (plus `version` and `difyctl.channel`) and owns the name
// FORMAT and the per-channel version form. Producer scripts call this;
// `validate` is the release gate.
//
// Subcommands:
// tag <version> -> <tagPrefix><version>
@@ -18,31 +19,109 @@
import { readFileSync, realpathSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import {
assetNameFor,
channelByName,
channelNames,
CHANNELS,
channelVersionProblem,
checksumsName,
compatProblems,
edgeVersionFrom,
edgeVersionProblem,
isWithinCompatWindow,
releaseConfigProblems,
tagName,
} from './lib/release-rules.mjs'
const PKG_URL = new URL('../package.json', import.meta.url)
const BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/
const SEMVER_CORE_LEN = 3
class UsageError extends Error {}
// Add channels here: { name, prerelease, versionForm }.
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}$/ },
]
// Arrow so TS infers `never`, keeping expression positions like `x ?? die(...)` typed.
const die = (msg) => {
throw new UsageError(msg)
const channelByName = (name) => CHANNELS.find((c) => c.name === name)
const channelNames = () => CHANNELS.map((c) => c.name).join(', ')
function parsePrecedence(v) {
const s = String(v).replace(/^v/, '').replace(/\+.*$/, '')
const i = s.indexOf('-')
const core = i === -1 ? s : s.slice(0, i)
const pre = i === -1 ? '' : s.slice(i + 1)
return { nums: core.split('.').map(Number), pre }
}
function loadPkg(pkgPath = PKG_URL) {
function versionCore(v) {
return String(v).replace(/^v/, '').replace(/\+.*$/, '').split('-')[0]
}
function edgeVersion(sha) {
if (!/^[0-9a-f]{7,40}$/.test(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}`)
return `${core}-edge.${sha}`
}
// Returns a problem string if `version` cannot be resolved under `channel`, else
// null. Shared by validateVersionForChannel (die-now) and validateVersionChannel
// (collect for the `validate` gate).
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
}
function validateVersionForChannel(version, channelName) {
const problem = channelVersionProblem(version, channelName)
if (problem) die(problem)
return `valid: ${version} is a ${channelName} version`
}
function comparePre(a, b) {
const aparts = a.split('.')
const bparts = b.split('.')
const len = Math.max(aparts.length, bparts.length)
for (let i = 0; i < len; i++) {
if (aparts[i] === undefined) return -1
if (bparts[i] === undefined) return 1
const an = /^\d+$/.test(aparts[i])
const bn = /^\d+$/.test(bparts[i])
if (an && bn) {
const d = Number(aparts[i]) - Number(bparts[i])
if (d !== 0) return d < 0 ? -1 : 1
} else if (an !== bn) {
return an ? -1 : 1
} else if (aparts[i] !== bparts[i]) {
return aparts[i] < bparts[i] ? -1 : 1
}
}
return 0
}
function comparePrecedence(a, b) {
const A = parsePrecedence(a)
const B = parsePrecedence(b)
for (let i = 0; i < SEMVER_CORE_LEN; i++) {
const x = A.nums[i] ?? 0
const y = B.nums[i] ?? 0
if (x !== y) return x < y ? -1 : 1
}
if (A.pre === B.pre) return 0
if (A.pre === '') return 1
if (B.pre === '') return -1
return comparePre(A.pre, B.pre)
}
function die(msg) {
process.stderr.write(`release-naming: ${msg}\n`)
process.exit(1)
}
// Tests point this at a fixture manifest so their assertions stay fixed while
// the real version and compat window move with every release. The name is
// mirrored in test/fixtures/pkg-manifest.ts rather than imported from here,
// because this file's shebang breaks the Windows test runner.
const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH'
function loadPkg() {
const pkgPath = process.env[PKG_PATH_ENV] || new URL('../package.json', import.meta.url)
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
if (!pkg.difyctl?.release) die('cli/package.json missing difyctl.release')
return {
@@ -63,7 +142,7 @@ function githubEnv() {
minDify: compat.minDify,
maxDify: compat.maxDify,
tagPrefix: release.tagPrefix,
difyctlTag: tagName(release, version),
difyctlTag: `${release.tagPrefix}${version}`,
}
return Object.entries(fields)
.map(([k, v]) => `${k}=${v}`)
@@ -75,19 +154,58 @@ function requireVersion(version) {
return version
}
function assetName(release, version, id) {
const target = release.targets.find((t) => t.id === id)
if (!target) die(`unknown target id: ${id}`)
const suffix = target.exe ? '.exe' : ''
return `${release.tagPrefix}${version}-${id}${suffix}`
}
function validateRelease(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
}
function validateVersionChannel(version, channel) {
const problem = channelVersionProblem(version, channel)
return problem ? [problem] : []
}
function main(argv) {
const [cmd, ...rest] = argv
switch (cmd) {
case 'tag':
return tagName(loadPkg().release, requireVersion(rest[0]))
case 'asset': {
return `${loadPkg().release.tagPrefix}${requireVersion(rest[0])}`
case 'asset':
return assetName(
loadPkg().release,
requireVersion(rest[0]),
rest[1] ?? die('target id is required'),
)
case 'checksums': {
const { release } = loadPkg()
const version = requireVersion(rest[0])
const id = rest[1] ?? die('target id is required')
return assetNameFor(release, version, id) ?? die(`unknown target id: ${id}`)
return `${release.tagPrefix}${requireVersion(rest[0])}${release.checksumsSuffix}`
}
case 'checksums':
return checksumsName(loadPkg().release, requireVersion(rest[0]))
case 'tag-prefix':
return loadPkg().release.tagPrefix
case 'targets':
@@ -103,7 +221,10 @@ function main(argv) {
const difyVersion = requireVersion(rest[0])
if (!compat.minDify || !compat.maxDify)
die('cli/package.json missing difyctl.compat.minDify/maxDify')
if (!isWithinCompatWindow(difyVersion, compat))
if (
comparePrecedence(difyVersion, compat.minDify) < 0 ||
comparePrecedence(difyVersion, compat.maxDify) > 0
)
die(
`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`,
)
@@ -115,31 +236,19 @@ function main(argv) {
return String(ch.prerelease)
}
case 'validate': {
const { version, channel, compat, release } = loadPkg()
const versionProblem = channelVersionProblem(version, channel)
const problems = [
...releaseConfigProblems(release),
...compatProblems(compat),
...(versionProblem ? [versionProblem] : []),
]
const { version, channel, release } = loadPkg()
const problems = [...validateRelease(release), ...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}`
}
case 'edge-version': {
const { version } = loadPkg()
const sha = rest[0]
const problem = edgeVersionProblem(version, sha)
if (problem) die(problem)
return edgeVersionFrom(version, sha)
}
case 'validate-version': {
const version = requireVersion(rest[0])
const channel = rest[1] ?? die('channel argument is required')
const problem = channelVersionProblem(version, channel)
if (problem) die(problem)
return `valid: ${version} is a ${channel} version`
}
case 'edge-version':
return edgeVersion(rest[0])
case 'validate-version':
return validateVersionForChannel(
requireVersion(rest[0]),
rest[1] ?? die('channel argument is required'),
)
default:
die(`unknown subcommand: ${cmd ?? '(none)'}`)
}
@@ -147,14 +256,14 @@ function main(argv) {
const invokedDirectly =
process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
if (invokedDirectly) {
try {
process.stdout.write(`${main(process.argv.slice(2))}\n`)
} catch (e) {
if (!(e instanceof UsageError)) throw e
process.stderr.write(`release-naming: ${e.message}\n`)
process.exit(1)
}
}
if (invokedDirectly) process.stdout.write(`${main(process.argv.slice(2))}\n`)
export { loadPkg, main }
export {
assetName,
channelByName,
CHANNELS,
edgeVersion,
loadPkg,
validateVersionForChannel,
versionCore,
}
+101 -56
View File
@@ -1,77 +1,122 @@
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { main } from './release-naming.mjs'
import { FIXTURE_COMPAT, pkgManifestEnv } from '../test/fixtures/pkg-manifest'
describe('release-naming argument handling', () => {
it.each([
[['tag'], 'version argument is required'],
[['asset'], 'version argument is required'],
[['checksums'], 'version argument is required'],
[['compat-check'], 'version argument is required'],
[['validate-version'], 'version argument is required'],
[['prerelease'], 'channel argument is required'],
[['edge-version'], 'git short sha'],
[['bogus'], 'unknown subcommand'],
[[], 'unknown subcommand'],
])('rejects %j', (args, message) => {
expect(() => main(args)).toThrow(message)
const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url))
function run(
args: string[],
env: Record<string, string> = {},
): { code: number; stdout: string; stderr: string } {
try {
const stdout = execFileSync('node', [SCRIPT, ...args], {
encoding: 'utf8',
env: { ...process.env, ...env },
})
return { code: 0, stdout, stderr: '' }
} catch (e) {
const err = e as { status?: number; stdout?: string; stderr?: string }
return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
}
}
describe('release-naming compat-check', () => {
const { minDify, maxDify } = FIXTURE_COMPAT // 2.0.0 .. 2.5.0
const pkgEnv = pkgManifestEnv()
const compatCheck = (difyVersion?: string) =>
run(difyVersion === undefined ? ['compat-check'] : ['compat-check', difyVersion], pkgEnv).code
it('accepts a version inside the window', () => {
expect(compatCheck('2.3.0')).toBe(0)
})
it('rejects an unknown target id', () => {
expect(() => main(['asset', '9.9.9', 'solaris-sparc'])).toThrow('unknown target id')
it('accepts the inclusive lower bound', () => {
expect(compatCheck(minDify)).toBe(0)
})
it('accepts the inclusive upper bound', () => {
expect(compatCheck(maxDify)).toBe(0)
})
it('accepts a v-prefixed tag', () => {
expect(compatCheck('v2.3.0')).toBe(0)
})
it('rejects a version below the lower bound', () => {
expect(compatCheck('1.9.9')).not.toBe(0)
})
it('rejects a version above the upper bound', () => {
expect(compatCheck('2.5.1')).not.toBe(0)
})
it('treats a prerelease of the lower bound as below it', () => {
expect(compatCheck(`${minDify}-rc1`)).not.toBe(0)
})
it('ignores build metadata on the bound', () => {
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)
})
})
describe('release-naming output shape', () => {
it('emits every key CI reads from github-env', () => {
const out = main(['github-env'])
for (const key of [
'version',
'channel',
'prerelease',
'minDify',
'maxDify',
'tagPrefix',
'difyctlTag',
])
expect(out).toMatch(new RegExp(`^${key}=.+$`, 'm'))
expect(out).not.toMatch(/=undefined$/m)
describe('release-naming github-env', () => {
it('emits difyctlTag = tagPrefix + version', () => {
const { stdout } = run(['github-env'])
expect(stdout).toMatch(/^difyctlTag=difyctl-v0\.2\.0-alpha$/m)
})
it('emits difyctlTag as tagPrefix immediately followed by version', () => {
const env = Object.fromEntries(
main(['github-env'])
.split('\n')
.filter(Boolean)
.map((line: string) => line.split(/=(.*)/s).slice(0, 2)),
)
expect(env.difyctlTag).toBe(`${env.tagPrefix}${env.version}`)
it('still emits the existing trace fields', () => {
const { stdout } = run(['github-env'])
for (const key of ['version', 'channel', 'prerelease', 'minDify', 'maxDify', 'tagPrefix'])
expect(stdout).toMatch(new RegExp(`^${key}=`, 'm'))
})
it('lists one channel per line, including edge', () => {
const out = main(['channels'])
expect(out.trim().split('\n').length).toBeGreaterThan(1)
expect(out).toMatch(/^edge$/m)
// The only assertion against the live manifest: the window must exist and be
// well-formed, whatever release it currently points at.
it('emits a well-formed compat window from the real cli/package.json', () => {
const { stdout } = run(['github-env'])
expect(stdout).toMatch(/^minDify=\d+\.\d+\.\d+$/m)
expect(stdout).toMatch(/^maxDify=\d+\.\d+\.\d+$/m)
})
})
it('emits targets as bunTarget<TAB>id<TAB>0|1 (release-build.sh parses this)', () => {
for (const line of main(['targets']).trim().split('\n'))
expect(line).toMatch(/^\S+\t\S+\t[01]$/)
describe('release-naming edge channel', () => {
it('lists edge among channels', () => {
expect(run(['channels']).stdout).toMatch(/^edge$/m)
})
it('reports prerelease as a boolean per channel', () => {
expect(main(['prerelease', 'stable']).trim()).toBe('false')
expect(main(['prerelease', 'alpha']).trim()).toBe('true')
expect(() => main(['prerelease', 'nightly'])).toThrow('unknown channel')
it('edge-version derives <pkgcore>-edge.<sha> from the package version', () => {
// package.json version is 0.2.0-alpha -> core 0.2.0
expect(run(['edge-version', '2fd7b82']).stdout.trim()).toBe('0.2.0-edge.2fd7b82')
})
it('derives an edge version from the packaged version and a sha', () => {
expect(main(['edge-version', '2fd7b82']).trim()).toMatch(/-edge\.2fd7b82$/)
it('edge-version accepts a 40-char sha', () => {
const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000'
expect(run(['edge-version', sha]).stdout.trim()).toBe(`0.2.0-edge.${sha}`)
})
it('validates a version against a channel form', () => {
expect(main(['validate-version', '0.1.0-edge.2fd7b82', 'edge'])).toContain('valid')
expect(() => main(['validate-version', '0.1.0-rc.1', 'edge'])).toThrow(
'does not match the edge channel form',
)
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('validate-version rejects an rc string under the edge channel', () => {
expect(run(['validate-version', '0.1.0-rc.1', 'edge']).code).not.toBe(0)
})
})
+82 -71
View File
@@ -1,23 +1,13 @@
#!/usr/bin/env node
// 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'
import { existsSync, readFileSync } from 'node:fs'
import { assetName, loadPkg, validateVersionForChannel } 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 die(msg) {
process.stderr.write(`release-r2-edge: ${msg}\n`)
process.exit(1)
}
function parseArgs(argv) {
@@ -38,83 +28,104 @@ function requireArgs(args, keys) {
}
}
// checksums lines are "<sha256> <assetName>"
function shaMap(checksumsPath) {
const map = new Map()
for (const line of readFileSync(checksumsPath, 'utf8').split('\n')) {
const m = line.match(/^([0-9a-f]{64})\s+(\S+)$/i)
if (m) map.set(m[2], m[1])
}
return map
}
function emitManifest(args) {
requireArgs(args, ['channel', 'version', 'commit', 'build-date', 'base-url', 'checksums'])
const versionProblem = channelVersionProblem(args.version, args.channel)
if (versionProblem) die(versionProblem)
validateVersionForChannel(args.version, args.channel)
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}`)
const shas = shaMap(args.checksums)
return renderManifest({
binName: release.binName,
const targetLines = release.targets
.map((t) => {
const asset = assetName(release, args.version, t.id)
const sha = shas.get(asset)
if (!sha) die(`no sha256 for ${asset} in ${args.checksums}`)
// one target per line: install-r2.sh grep/sed depends on this layout
return ` ${JSON.stringify(t.id)}: { "asset": ${JSON.stringify(asset)}, "sha256": ${JSON.stringify(sha)} }`
})
.join(',\n')
const head = {
schema: 1,
name: release.binName,
channel: args.channel,
version: args.version,
commit: args.commit,
buildDate: args['build-date'],
compat,
compat: { minDify: compat.minDify, maxDify: compat.maxDify },
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`)
}
const headLines = Object.entries(head)
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`)
.join(',\n')
process.stdout.write(`{\n${headLines},\n "targets": {\n${targetLines}\n }\n}\n`)
}
// Absent file = no reconciliation (caller could not list); empty file = no survivors.
// Newline-delimited dir names of binaries that still exist in R2. 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'))
const set = new Set()
for (const line of readFileSync(path, 'utf8').split('\n')) {
const d = line.trim()
if (d) set.add(d)
}
return set
}
function emitIndex(args) {
requireArgs(args, ['current', 'channel', 'version', 'commit', 'build-date'])
const index = buildIndex({
channel: args.channel,
// empty / "-" / missing = no ledger yet (first publish)
let current = { schema: 1, channel: args.channel, builds: [] }
if (args.current !== '-' && existsSync(args.current)) {
const raw = readFileSync(args.current, 'utf8').trim()
if (raw && raw !== '-') {
try {
current = JSON.parse(raw)
} catch {
die(`current index at ${args.current} is not valid JSON`)
}
}
}
const entry = {
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)`)
dir: args.version,
}
const kept = (current.builds ?? []).filter((b) => b.version !== entry.version)
let builds = [entry, ...kept]
// Reconcile to binaries that still exist in R2: lifecycle/TTL on the bin prefix
// is the only deletion mechanism, so the ledger never advertises a build whose
// binary is gone. The new build is always kept (just uploaded). No count cap.
const existing = loadExistingDirs(args['existing-dirs'])
if (existing) builds = builds.filter((b) => b.dir === entry.dir || existing.has(b.dir))
const index = { schema: 1, channel: args.channel, updated: args['build-date'], builds }
process.stdout.write(`${JSON.stringify(index, null, 2)}\n`)
}
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)
}
const [cmd, ...rest] = process.argv.slice(2)
const args = parseArgs(rest)
switch (cmd) {
case 'manifest':
emitManifest(args)
break
case 'index':
emitIndex(args)
break
default:
die(`unknown subcommand: ${cmd ?? '(none)'} (expected: manifest | index)`)
}
export { main }
+241 -108
View File
@@ -1,28 +1,49 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { main as naming } from './release-naming.mjs'
import { main } from './release-r2-edge.mjs'
import { FIXTURE_COMPAT, pkgManifestEnv } from '../test/fixtures/pkg-manifest'
const SCRIPT = fileURLToPath(new URL('./release-r2-edge.mjs', import.meta.url))
const PKG_ENV = pkgManifestEnv()
function run(args: string[]): { code: number; stdout: string; stderr: string } {
try {
return {
code: 0,
stdout: execFileSync('node', [SCRIPT, ...args], {
encoding: 'utf8',
env: { ...process.env, ...PKG_ENV },
}),
stderr: '',
}
} catch (e) {
const err = e as { status?: number; stdout?: string; stderr?: string }
return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
}
}
// ---- manifest ----
function writeChecksums(version: string): string {
const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-'))
const ids = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
const lines = ids.map((id, i) => {
const exe = id === 'windows-x64' ? '.exe' : ''
const sha = String(i).repeat(64)
return `${sha} difyctl-v${version}-${id}${exe}`
})
const file = join(dir, `difyctl-v${version}-checksums.txt`)
writeFileSync(file, `${lines.join('\n')}\n`)
return file
}
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
@@ -35,23 +56,28 @@ type ManifestJson = {
targets: Record<string, { asset: string; sha256: string }>
}
type IndexBuild = {
version: string
commit: string
buildDate: string
dir: string
}
type IndexJson = {
schema: number
channel: string
updated: string
builds: { version: string; commit: string; buildDate: string; dir: string }[]
builds: IndexBuild[]
}
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 [
function buildManifest(version = VERSION): {
code: number
json: ManifestJson
stdout: string
stderr: string
} {
const checksums = writeChecksums(version)
const r = run([
'manifest',
'--channel',
'edge',
@@ -64,122 +90,229 @@ function manifestArgs(version = VERSION, ids = TARGET_IDS): string[] {
'--base-url',
BASE_URL,
'--checksums',
writeChecksums(ids),
]
checksums,
])
return {
code: r.code,
json: (r.code === 0 ? JSON.parse(r.stdout) : null) as ManifestJson,
stdout: r.stdout,
stderr: r.stderr,
}
}
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('emits the core pointer fields', () => {
const { json } = buildManifest()
expect(json.schema).toBe(1)
expect(json.name).toBe('difyctl')
expect(json.channel).toBe('edge')
expect(json.version).toBe(VERSION)
expect(json.commit).toBe('abc1234')
expect(json.buildDate).toBe('2026-06-14T12:00:00Z')
expect(json.baseUrl).toBe(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('carries the compat window from package.json', () => {
const { json } = buildManifest()
expect(json.compat).toEqual(FIXTURE_COMPAT)
})
it('includes every target the release config declares', () => {
expect(Object.keys(buildManifest().targets).sort()).toEqual([...TARGET_IDS].sort())
it('lists all 5 targets with asset name + sha256 from the checksums file', () => {
const { json } = buildManifest()
expect(Object.keys(json.targets).sort()).toEqual([
'darwin-arm64',
'darwin-x64',
'linux-arm64',
'linux-x64',
'windows-x64',
])
expect(json.targets['linux-x64'].asset).toBe(`difyctl-v${VERSION}-linux-x64`)
expect(json.targets['windows-x64'].asset).toBe(`difyctl-v${VERSION}-windows-x64.exe`)
expect(json.targets['linux-x64'].sha256).toMatch(/^\d{64}$/)
})
it('renders each target on a single line (installer greps it)', () => {
const { stdout } = buildManifest()
expect(stdout).toMatch(/^ {4}"linux-x64": \{ "asset": ".*", "sha256": ".*" \}/m)
})
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')
const { code } = buildManifest('0.1.0-rc.1')
expect(code).not.toBe(0)
})
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')
const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-'))
const file = join(dir, `difyctl-v${VERSION}-checksums.txt`)
writeFileSync(file, `${'0'.repeat(64)} difyctl-v${VERSION}-linux-x64\n`) // only 1 of 5
const r = run([
'manifest',
'--channel',
'edge',
'--version',
VERSION,
'--commit',
'abc1234',
'--build-date',
'2026-06-14T12:00:00Z',
'--base-url',
BASE_URL,
'--checksums',
file,
])
expect(r.code).not.toBe(0)
})
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')
const r = run([
'manifest',
'--channel',
'edge',
'--version',
'--commit',
'abc1234',
'--build-date',
'2026-06-14T12:00:00Z',
'--base-url',
'https://x',
'--checksums',
'/nonexistent',
])
expect(r.code).not.toBe(0)
})
})
describe('release-r2-edge index', () => {
const B1 = { version: '0.1.0-edge.aaaaaaa', commit: 'aaaaaaa', buildDate: '2026-06-14T09:00:00Z' }
// ---- index ----
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 [
function runIndex(
currentContent: string | null,
build: Record<string, string>,
existingDirs?: string[],
) {
let currentArg = '-'
if (currentContent !== null) {
const dir = mkdtempSync(join(tmpdir(), 'difyctl-index-'))
currentArg = join(dir, 'index.json')
writeFileSync(currentArg, currentContent)
}
const extra: string[] = []
if (existingDirs !== undefined) {
const dir = mkdtempSync(join(tmpdir(), 'difyctl-existing-'))
const f = join(dir, 'existing.txt')
writeFileSync(f, `${existingDirs.join('\n')}\n`)
extra.push('--existing-dirs', f)
}
const r = spawnSync(
'node',
[
SCRIPT,
'index',
'--current',
currentArg,
'--channel',
'edge',
'--version',
B1.version,
build.version,
'--commit',
B1.commit,
build.commit,
'--build-date',
B1.buildDate,
build.buildDate,
...extra,
]
],
{ encoding: 'utf8' },
)
return {
code: r.status ?? 1,
index: (r.status === 0 ? JSON.parse(r.stdout) : null) as IndexJson,
}
}
const buildIndex = (currentContent: string | null, existingDirs?: string[]) =>
JSON.parse(main(indexArgs(currentContent, existingDirs))) as IndexJson
const B1 = { version: '0.1.0-edge.aaaaaaa', commit: 'aaaaaaa', buildDate: '2026-06-14T09:00:00Z' }
const B2 = { version: '0.1.0-edge.bbbbbbb', commit: 'bbbbbbb', buildDate: '2026-06-14T10:00:00Z' }
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 }])
describe('release-r2-edge index', () => {
it('creates a fresh index from a missing current (arg "-")', () => {
const { index } = runIndex(null, B1)
expect(index.schema).toBe(1)
expect(index.channel).toBe('edge')
expect(index.builds).toHaveLength(1)
expect(index.builds[0]).toMatchObject({
version: B1.version,
commit: B1.commit,
dir: B1.version,
})
})
it('dies on a current index that is not valid JSON', () => {
expect(() => main(indexArgs('{not json'))).toThrow('not valid JSON')
it('treats an empty current file as fresh (first publish, curl wrote nothing)', () => {
const { code, index } = runIndex('', B1)
expect(code).toBe(0)
expect(index.builds).toHaveLength(1)
})
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])
it('treats a "-"-content current file as fresh (curl 404 fallback)', () => {
const { code, index } = runIndex('-\n', B1)
expect(code).toBe(0)
expect(index.builds).toHaveLength(1)
})
it('prepends the new build (publish order; newest at [0])', () => {
const current = JSON.stringify({
schema: 1,
channel: 'edge',
builds: [
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
],
})
const { index } = runIndex(current, B2)
expect(index.builds.map((b) => b.version)).toEqual([B2.version, B1.version])
})
it('dedups a re-cut of the same version (no duplicate, moves to top)', () => {
const current = JSON.stringify({
schema: 1,
channel: 'edge',
builds: [
{ version: B2.version, commit: B2.commit, buildDate: B2.buildDate, dir: B2.version },
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
],
})
const { index } = runIndex(current, B1) // re-cut B1
expect(index.builds.map((b) => b.version)).toEqual([B1.version, B2.version])
})
it('reconciles to surviving binary dirs (drops a build whose binary expired)', () => {
const current = JSON.stringify({
schema: 1,
channel: 'edge',
builds: [
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
],
})
// B1's binary is gone (not in existing); the new B2 is always kept.
const { index } = runIndex(current, B2, [B2.version])
expect(index.builds.map((b) => b.version)).toEqual([B2.version])
})
it('keeps the new build even when it is absent from the existing-dirs list', () => {
const { index } = runIndex(null, B1, []) // empty survivors, fresh ledger
expect(index.builds.map((b) => b.version)).toEqual([B1.version])
})
it('does not reconcile when no --existing-dirs is given (list unavailable)', () => {
const current = JSON.stringify({
schema: 1,
channel: 'edge',
builds: [
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
],
})
const { index } = runIndex(current, B2) // no existing-dirs → keep all
expect(index.builds.map((b) => b.version)).toEqual([B2.version, B1.version])
})
it('dies on a non-empty current file that is not valid JSON', () => {
const { code } = runIndex('{not json', B1)
expect(code).not.toBe(0)
})
})
+91
View File
@@ -0,0 +1,91 @@
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const SCRIPT = fileURLToPath(new URL('./release-r2-publish.sh', import.meta.url))
// Stub `aws` + `curl` + `node` as shell functions that just log action verbs to
// $ORDER_LOG, then run the publish `main` and assert the order of operations.
function runPublish(): { code: number; order: string[]; stderr: string } {
const stub = [
'ORDER_LOG="$(mktemp)"',
'aws() {',
' case "$*" in',
' *"list-objects-v2"*) echo list-survivors >>"$ORDER_LOG" ;;',
' *" cp "*"/index.json"*) echo put-index >>"$ORDER_LOG" ;;',
' *" cp "*"/manifest.json"*) echo put-manifest >>"$ORDER_LOG" ;;',
' *" sync "*) echo sync-binaries >>"$ORDER_LOG" ;;',
' *"head-object"*) echo head-verify >>"$ORDER_LOG" ;;',
' *) : ;;',
' esac',
'}',
'curl() { echo "{}"; }',
'node() {',
' case "$*" in',
' *release-naming.mjs*targets*)',
" printf 'bun-linux-x64\\tlinux-x64\\t0\\nbun-linux-arm64\\tlinux-arm64\\t0\\nbun-darwin-x64\\tdarwin-x64\\t0\\nbun-darwin-arm64\\tdarwin-arm64\\t0\\nbun-windows-x64\\twindows-x64\\t1\\n' ;;",
" *release-naming.mjs*' asset '*) printf 'difyctl-vX\\n' ;;",
" *release-r2-edge.mjs*' index '*) echo '{}' ;;",
" *release-r2-edge.mjs*' manifest '*) echo '{}' ;;",
' *) : ;;',
' esac',
'}',
].join('\n')
const program = [
stub,
`. "${SCRIPT}"`,
'publish_main edge 0.1.0-edge.2fd7b82',
'cat "$ORDER_LOG"',
].join('\n')
// bash, NOT sh: the script uses BASH_SOURCE + process substitution.
const r = spawnSync('bash', ['-c', program], {
encoding: 'utf8',
env: {
...process.env,
RELEASE_PUBLISH_LIB: '1',
DIFYCTL_R2_S3_ENDPOINT: 'https://endpoint.example',
DIFYCTL_R2_BUCKET: 'cli-dev',
DIFYCTL_R2_PUBLIC_BASE: 'https://pub.example.r2.dev',
DIST_DIR: '/tmp',
},
})
return {
code: r.status ?? 1,
order: (r.stdout ?? '').trim().split('\n').filter(Boolean),
stderr: r.stderr ?? '',
}
}
describe('release-r2-publish order', () => {
it('uploads binaries, verifies, lists survivors, then index, then manifest', () => {
const { code, order } = runPublish()
expect(code).toBe(0)
expect(order.indexOf('sync-binaries')).toBeLessThan(order.indexOf('head-verify'))
expect(order.indexOf('head-verify')).toBeLessThan(order.indexOf('list-survivors'))
expect(order.indexOf('list-survivors')).toBeLessThan(order.indexOf('put-index'))
expect(order.indexOf('put-index')).toBeLessThan(order.indexOf('put-manifest'))
// pointer is never pruned here — deletion is owned by the R2 lifecycle rule
expect(order).not.toContain('prune')
})
it('exits non-zero when no targets resolve (head-verify safety gate)', () => {
const stub = [
'aws() { :; }',
'curl() { echo "{}"; }',
'node() { case "$*" in *release-naming.mjs*targets*) : ;; *) echo "{}" ;; esac; }',
].join('\n')
const program = [stub, `. "${SCRIPT}"`, 'publish_main edge 0.1.0-edge.2fd7b82'].join('\n')
const r = spawnSync('bash', ['-c', program], {
encoding: 'utf8',
env: {
...process.env,
RELEASE_PUBLISH_LIB: '1',
DIFYCTL_R2_S3_ENDPOINT: 'https://endpoint.example',
DIFYCTL_R2_BUCKET: 'cli-dev',
DIFYCTL_R2_PUBLIC_BASE: 'https://pub.example.r2.dev',
DIST_DIR: '/tmp',
},
})
expect(r.status).not.toBe(0)
})
})
+57
View File
@@ -0,0 +1,57 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// Mirrors PKG_PATH_ENV in scripts/release-naming.mjs, which cannot be imported
// here: its shebang breaks the Windows test runner. Divergence is self-
// reporting, not silent — the script would fall back to the real
// cli/package.json and every fixture-window assertion would fail.
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.
// 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_TARGET_IDS = [
'linux-x64',
'linux-arm64',
'darwin-x64',
'darwin-arm64',
'windows-x64',
] as const
const FIXTURE_RELEASE = {
tagPrefix: 'difyctl-v',
binName: 'difyctl',
checksumsSuffix: '-checksums.txt',
targets: FIXTURE_TARGET_IDS.map((id) => ({
id,
bunTarget: `bun-${id}`,
exe: id.startsWith('windows'),
})),
}
export type PkgManifestOverrides = {
version?: string
channel?: string
compat?: { minDify: string; maxDify: string }
}
// Returns the env additions that point a spawned script at the fixture.
export function pkgManifestEnv(overrides: PkgManifestOverrides = {}): Record<string, string> {
const manifest = {
version: overrides.version ?? '0.2.0-alpha',
difyctl: {
channel: overrides.channel ?? 'alpha',
compat: overrides.compat ?? FIXTURE_COMPAT,
release: FIXTURE_RELEASE,
},
}
const path = join(mkdtempSync(join(tmpdir(), 'difyctl-pkg-')), 'package.json')
writeFileSync(path, JSON.stringify(manifest))
return { [PKG_PATH_ENV]: path }
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "dify-agent"
version = "1.16.0"
version = "1.16.1"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12,<4.0"
+1 -1
View File
@@ -581,7 +581,7 @@ wheels = [
[[package]]
name = "dify-agent"
version = "1.16.0"
version = "1.16.1"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
+7 -7
View File
@@ -220,7 +220,7 @@ services:
# API service
api:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.16.0
image: langgenius/dify-api:1.16.1
environment:
MODE: api
SENTRY_DSN: ${API_SENTRY_DSN:-}
@@ -271,7 +271,7 @@ services:
# WebSocket service for workflow collaboration.
api_websocket:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.16.0
image: langgenius/dify-api:1.16.1
profiles:
- collaboration
environment:
@@ -297,7 +297,7 @@ services:
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
<<: *shared-worker-config
image: langgenius/dify-api:1.16.0
image: langgenius/dify-api:1.16.1
environment:
MODE: worker
SENTRY_DSN: ${API_SENTRY_DSN:-}
@@ -347,7 +347,7 @@ services:
# Celery beat for scheduling periodic tasks.
worker_beat:
<<: *shared-worker-beat-config
image: langgenius/dify-api:1.16.0
image: langgenius/dify-api:1.16.1
environment:
MODE: beat
depends_on:
@@ -380,7 +380,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.16.0
image: langgenius/dify-web:1.16.1
restart: always
env_file:
- path: ./envs/core-services/web.env
@@ -542,7 +542,7 @@ services:
# on port 3128, which only allows agent_backend /agent-stub/ and the Dify API
# /files/* endpoints (see ssrf_proxy/squid-agent.conf.template).
local_sandbox:
image: langgenius/dify-agent-local-sandbox:1.16.0
image: langgenius/dify-agent-local-sandbox:1.16.1
restart: always
env_file:
- path: ./envs/core-services/local-sandbox.env
@@ -651,7 +651,7 @@ services:
# Dify Agent backend service.
agent_backend:
image: langgenius/dify-agent-backend:1.16.0
image: langgenius/dify-agent-backend:1.16.1
restart: always
env_file:
- path: ./envs/core-services/dify-agent.env
+7 -7
View File
@@ -226,7 +226,7 @@ services:
# API service
api:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.16.0
image: langgenius/dify-api:1.16.1
environment:
MODE: api
SENTRY_DSN: ${API_SENTRY_DSN:-}
@@ -277,7 +277,7 @@ services:
# WebSocket service for workflow collaboration.
api_websocket:
<<: *shared-api-worker-config
image: langgenius/dify-api:1.16.0
image: langgenius/dify-api:1.16.1
profiles:
- collaboration
environment:
@@ -303,7 +303,7 @@ services:
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
<<: *shared-worker-config
image: langgenius/dify-api:1.16.0
image: langgenius/dify-api:1.16.1
environment:
MODE: worker
SENTRY_DSN: ${API_SENTRY_DSN:-}
@@ -353,7 +353,7 @@ services:
# Celery beat for scheduling periodic tasks.
worker_beat:
<<: *shared-worker-beat-config
image: langgenius/dify-api:1.16.0
image: langgenius/dify-api:1.16.1
environment:
MODE: beat
depends_on:
@@ -386,7 +386,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.16.0
image: langgenius/dify-web:1.16.1
restart: always
env_file:
- path: ./envs/core-services/web.env
@@ -548,7 +548,7 @@ services:
# on port 3128, which only allows agent_backend /agent-stub/ and the Dify API
# /files/* endpoints (see ssrf_proxy/squid-agent.conf.template).
local_sandbox:
image: langgenius/dify-agent-local-sandbox:1.16.0
image: langgenius/dify-agent-local-sandbox:1.16.1
restart: always
env_file:
- path: ./envs/core-services/local-sandbox.env
@@ -657,7 +657,7 @@ services:
# Dify Agent backend service.
agent_backend:
image: langgenius/dify-agent-backend:1.16.0
image: langgenius/dify-agent-backend:1.16.1
restart: always
env_file:
- path: ./envs/core-services/dify-agent.env
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dify-web",
"version": "1.16.0",
"version": "1.16.1",
"private": true,
"type": "module",
"imports": {