Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1c32e1bc8 |
@@ -1,402 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
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/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,149 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,297 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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)])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
+63
-172
@@ -1,9 +1,8 @@
|
||||
#!/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`) and owns the name
|
||||
// FORMAT and the per-channel version form. Producer scripts call this;
|
||||
// `validate` is the release gate.
|
||||
// `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.
|
||||
//
|
||||
// Subcommands:
|
||||
// tag <version> -> <tagPrefix><version>
|
||||
@@ -19,109 +18,31 @@
|
||||
|
||||
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 BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/
|
||||
const SEMVER_CORE_LEN = 3
|
||||
const PKG_URL = new URL('../package.json', import.meta.url)
|
||||
|
||||
// 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}$/ },
|
||||
]
|
||||
class UsageError extends Error {}
|
||||
|
||||
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 }
|
||||
// Arrow so TS infers `never`, keeping expression positions like `x ?? die(...)` typed.
|
||||
const die = (msg) => {
|
||||
throw new UsageError(msg)
|
||||
}
|
||||
|
||||
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)
|
||||
function loadPkg(pkgPath = PKG_URL) {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||
if (!pkg.difyctl?.release) die('cli/package.json missing difyctl.release')
|
||||
return {
|
||||
@@ -142,7 +63,7 @@ function githubEnv() {
|
||||
minDify: compat.minDify,
|
||||
maxDify: compat.maxDify,
|
||||
tagPrefix: release.tagPrefix,
|
||||
difyctlTag: `${release.tagPrefix}${version}`,
|
||||
difyctlTag: tagName(release, version),
|
||||
}
|
||||
return Object.entries(fields)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
@@ -154,58 +75,19 @@ 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 `${loadPkg().release.tagPrefix}${requireVersion(rest[0])}`
|
||||
case 'asset':
|
||||
return assetName(
|
||||
loadPkg().release,
|
||||
requireVersion(rest[0]),
|
||||
rest[1] ?? die('target id is required'),
|
||||
)
|
||||
case 'checksums': {
|
||||
return tagName(loadPkg().release, requireVersion(rest[0]))
|
||||
case 'asset': {
|
||||
const { release } = loadPkg()
|
||||
return `${release.tagPrefix}${requireVersion(rest[0])}${release.checksumsSuffix}`
|
||||
const version = requireVersion(rest[0])
|
||||
const id = rest[1] ?? die('target id is required')
|
||||
return assetNameFor(release, version, id) ?? die(`unknown target id: ${id}`)
|
||||
}
|
||||
case 'checksums':
|
||||
return checksumsName(loadPkg().release, requireVersion(rest[0]))
|
||||
case 'tag-prefix':
|
||||
return loadPkg().release.tagPrefix
|
||||
case 'targets':
|
||||
@@ -221,10 +103,7 @@ function main(argv) {
|
||||
const difyVersion = requireVersion(rest[0])
|
||||
if (!compat.minDify || !compat.maxDify)
|
||||
die('cli/package.json missing difyctl.compat.minDify/maxDify')
|
||||
if (
|
||||
comparePrecedence(difyVersion, compat.minDify) < 0 ||
|
||||
comparePrecedence(difyVersion, compat.maxDify) > 0
|
||||
)
|
||||
if (!isWithinCompatWindow(difyVersion, compat))
|
||||
die(
|
||||
`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`,
|
||||
)
|
||||
@@ -236,19 +115,31 @@ function main(argv) {
|
||||
return String(ch.prerelease)
|
||||
}
|
||||
case 'validate': {
|
||||
const { version, channel, release } = loadPkg()
|
||||
const problems = [...validateRelease(release), ...validateVersionChannel(version, channel)]
|
||||
const { version, channel, compat, release } = loadPkg()
|
||||
const versionProblem = channelVersionProblem(version, channel)
|
||||
const problems = [
|
||||
...releaseConfigProblems(release),
|
||||
...compatProblems(compat),
|
||||
...(versionProblem ? [versionProblem] : []),
|
||||
]
|
||||
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':
|
||||
return edgeVersion(rest[0])
|
||||
case 'validate-version':
|
||||
return validateVersionForChannel(
|
||||
requireVersion(rest[0]),
|
||||
rest[1] ?? die('channel argument is required'),
|
||||
)
|
||||
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`
|
||||
}
|
||||
default:
|
||||
die(`unknown subcommand: ${cmd ?? '(none)'}`)
|
||||
}
|
||||
@@ -256,14 +147,14 @@ function main(argv) {
|
||||
|
||||
const invokedDirectly =
|
||||
process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
if (invokedDirectly) process.stdout.write(`${main(process.argv.slice(2))}\n`)
|
||||
|
||||
export {
|
||||
assetName,
|
||||
channelByName,
|
||||
CHANNELS,
|
||||
edgeVersion,
|
||||
loadPkg,
|
||||
validateVersionForChannel,
|
||||
versionCore,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export { loadPkg, main }
|
||||
|
||||
@@ -1,122 +1,77 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FIXTURE_COMPAT, pkgManifestEnv } from '../test/fixtures/pkg-manifest'
|
||||
import { main } from './release-naming.mjs'
|
||||
|
||||
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)
|
||||
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)
|
||||
})
|
||||
|
||||
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)
|
||||
it('rejects an unknown target id', () => {
|
||||
expect(() => main(['asset', '9.9.9', 'solaris-sparc'])).toThrow('unknown target id')
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
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)
|
||||
})
|
||||
|
||||
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('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}`)
|
||||
})
|
||||
|
||||
// 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('lists one channel per line, including edge', () => {
|
||||
const out = main(['channels'])
|
||||
expect(out.trim().split('\n').length).toBeGreaterThan(1)
|
||||
expect(out).toMatch(/^edge$/m)
|
||||
})
|
||||
})
|
||||
|
||||
describe('release-naming edge channel', () => {
|
||||
it('lists edge among channels', () => {
|
||||
expect(run(['channels']).stdout).toMatch(/^edge$/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]$/)
|
||||
})
|
||||
|
||||
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('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 accepts a 40-char sha', () => {
|
||||
const sha = '2fd7b829e1f0aaaabbbbccccddddeeeeffff0000'
|
||||
expect(run(['edge-version', sha]).stdout.trim()).toBe(`0.2.0-edge.${sha}`)
|
||||
it('derives an edge version from the packaged version and a sha', () => {
|
||||
expect(main(['edge-version', '2fd7b82']).trim()).toMatch(/-edge\.2fd7b82$/)
|
||||
})
|
||||
|
||||
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)
|
||||
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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
#!/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 } from 'node:fs'
|
||||
import { assetName, loadPkg, validateVersionForChannel } from './release-naming.mjs'
|
||||
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'
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write(`release-r2-edge: ${msg}\n`)
|
||||
process.exit(1)
|
||||
class UsageError extends Error {}
|
||||
|
||||
// Arrow so TS infers `never`, keeping expression positions like `return die(...)` typed.
|
||||
const die = (msg) => {
|
||||
throw new UsageError(msg)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
@@ -28,104 +38,83 @@ 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'])
|
||||
validateVersionForChannel(args.version, args.channel)
|
||||
const versionProblem = channelVersionProblem(args.version, args.channel)
|
||||
if (versionProblem) die(versionProblem)
|
||||
|
||||
const { release, compat } = loadPkg()
|
||||
const shas = shaMap(args.checksums)
|
||||
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 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,
|
||||
return renderManifest({
|
||||
binName: release.binName,
|
||||
channel: args.channel,
|
||||
version: args.version,
|
||||
commit: args.commit,
|
||||
buildDate: args['build-date'],
|
||||
compat: { minDify: compat.minDify, maxDify: compat.maxDify },
|
||||
compat,
|
||||
baseUrl: args['base-url'],
|
||||
}
|
||||
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`)
|
||||
targets,
|
||||
})
|
||||
}
|
||||
|
||||
// Newline-delimited dir names of binaries that still exist in R2. Absent file =
|
||||
// no reconciliation (caller could not list); empty file = no survivors.
|
||||
// empty / "-" / missing = no ledger yet (first publish)
|
||||
function loadCurrentIndex(path) {
|
||||
if (path === '-' || !existsSync(path)) return null
|
||||
const raw = readFileSync(path, 'utf8').trim()
|
||||
if (!raw || raw === '-') return null
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return die(`current index at ${path} is not valid JSON`)
|
||||
}
|
||||
}
|
||||
|
||||
// Absent file = no reconciliation (caller could not list); empty file = no survivors.
|
||||
function loadExistingDirs(path) {
|
||||
if (!path || !existsSync(path)) return null
|
||||
const set = new Set()
|
||||
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
||||
const d = line.trim()
|
||||
if (d) set.add(d)
|
||||
}
|
||||
return set
|
||||
return parseDirList(readFileSync(path, 'utf8'))
|
||||
}
|
||||
|
||||
function emitIndex(args) {
|
||||
requireArgs(args, ['current', 'channel', 'version', 'commit', 'build-date'])
|
||||
|
||||
// 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 = {
|
||||
const index = buildIndex({
|
||||
channel: args.channel,
|
||||
version: args.version,
|
||||
commit: args.commit,
|
||||
buildDate: args['build-date'],
|
||||
dir: args.version,
|
||||
current: loadCurrentIndex(args.current),
|
||||
existingDirs: loadExistingDirs(args['existing-dirs']),
|
||||
})
|
||||
return `${JSON.stringify(index, null, 2)}\n`
|
||||
}
|
||||
|
||||
// Returns the exact bytes to write to stdout.
|
||||
function main(argv) {
|
||||
const [cmd, ...rest] = argv
|
||||
const args = parseArgs(rest)
|
||||
switch (cmd) {
|
||||
case 'manifest':
|
||||
return emitManifest(args)
|
||||
case 'index':
|
||||
return emitIndex(args)
|
||||
default:
|
||||
return die(`unknown subcommand: ${cmd ?? '(none)'} (expected: manifest | index)`)
|
||||
}
|
||||
const 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 [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)`)
|
||||
const invokedDirectly =
|
||||
process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
process.stdout.write(main(process.argv.slice(2)))
|
||||
} catch (e) {
|
||||
if (!(e instanceof UsageError)) throw e
|
||||
process.stderr.write(`release-r2-edge: ${e.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
export { main }
|
||||
|
||||
+108
-241
@@ -1,49 +1,28 @@
|
||||
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 { 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
|
||||
}
|
||||
import { main as naming } from './release-naming.mjs'
|
||||
import { main } from './release-r2-edge.mjs'
|
||||
|
||||
const VERSION = '0.1.0-edge.2fd7b82'
|
||||
const BASE_URL = 'https://example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82'
|
||||
|
||||
const TARGET_IDS = naming(['targets'])
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line: string) => line.split('\t')[1])
|
||||
|
||||
const ASSETS = new Map(TARGET_IDS.map((id: string) => [id, naming(['asset', VERSION, id]).trim()]))
|
||||
const assetFor = (id: string) => ASSETS.get(id) ?? ''
|
||||
|
||||
const PKG_ENV = Object.fromEntries(
|
||||
naming(['github-env'])
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line: string) => line.split(/=(.*)/s).slice(0, 2)),
|
||||
)
|
||||
|
||||
type ManifestJson = {
|
||||
schema: number
|
||||
name: string
|
||||
@@ -56,28 +35,23 @@ 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: IndexBuild[]
|
||||
builds: { version: string; commit: string; buildDate: string; dir: string }[]
|
||||
}
|
||||
|
||||
function buildManifest(version = VERSION): {
|
||||
code: number
|
||||
json: ManifestJson
|
||||
stdout: string
|
||||
stderr: string
|
||||
} {
|
||||
const checksums = writeChecksums(version)
|
||||
const r = run([
|
||||
function writeChecksums(ids: string[]): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-'))
|
||||
const lines = ids.map((id, i) => `${String(i).repeat(64)} ${assetFor(id)}`)
|
||||
const file = join(dir, 'checksums.txt')
|
||||
writeFileSync(file, `${lines.join('\n')}\n`)
|
||||
return file
|
||||
}
|
||||
|
||||
function manifestArgs(version = VERSION, ids = TARGET_IDS): string[] {
|
||||
return [
|
||||
'manifest',
|
||||
'--channel',
|
||||
'edge',
|
||||
@@ -90,229 +64,122 @@ function buildManifest(version = VERSION): {
|
||||
'--base-url',
|
||||
BASE_URL,
|
||||
'--checksums',
|
||||
checksums,
|
||||
])
|
||||
return {
|
||||
code: r.code,
|
||||
json: (r.code === 0 ? JSON.parse(r.stdout) : null) as ManifestJson,
|
||||
stdout: r.stdout,
|
||||
stderr: r.stderr,
|
||||
}
|
||||
writeChecksums(ids),
|
||||
]
|
||||
}
|
||||
|
||||
const buildManifest = () => JSON.parse(main(manifestArgs())) as ManifestJson
|
||||
|
||||
describe('release-r2-edge manifest', () => {
|
||||
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('passes the CLI arguments straight through to the manifest', () => {
|
||||
expect(buildManifest()).toMatchObject({
|
||||
schema: 1,
|
||||
channel: 'edge',
|
||||
version: VERSION,
|
||||
commit: 'abc1234',
|
||||
buildDate: '2026-06-14T12:00:00Z',
|
||||
baseUrl: BASE_URL,
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the compat window from package.json', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(json.compat).toEqual(FIXTURE_COMPAT)
|
||||
it('carries the compat window through from cli/package.json', () => {
|
||||
expect(buildManifest().compat).toEqual({ minDify: PKG_ENV.minDify, maxDify: PKG_ENV.maxDify })
|
||||
})
|
||||
|
||||
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('includes every target the release config declares', () => {
|
||||
expect(Object.keys(buildManifest().targets).sort()).toEqual([...TARGET_IDS].sort())
|
||||
})
|
||||
|
||||
it('rejects a version that does not match the channel form', () => {
|
||||
const { code } = buildManifest('0.1.0-rc.1')
|
||||
expect(code).not.toBe(0)
|
||||
expect(() => main(manifestArgs('0.1.0-rc.1'))).toThrow('does not match the edge channel form')
|
||||
})
|
||||
|
||||
it('dies when a target sha is missing from the checksums file', () => {
|
||||
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)
|
||||
expect(() => main(manifestArgs(VERSION, TARGET_IDS.slice(0, 1)))).toThrow('no sha256 for')
|
||||
})
|
||||
|
||||
it('rejects a malformed dropped-value argument (no silent misparse)', () => {
|
||||
// --version has no value; --commit must NOT be swallowed as the version
|
||||
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)
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- index ----
|
||||
describe('release-r2-edge index', () => {
|
||||
const B1 = { version: '0.1.0-edge.aaaaaaa', commit: 'aaaaaaa', buildDate: '2026-06-14T09:00:00Z' }
|
||||
|
||||
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,
|
||||
function indexArgs(currentContent: string | null, existingDirs?: string[]): string[] {
|
||||
let currentArg = '-'
|
||||
if (currentContent !== null) {
|
||||
currentArg = join(mkdtempSync(join(tmpdir(), 'difyctl-index-')), 'index.json')
|
||||
writeFileSync(currentArg, currentContent)
|
||||
}
|
||||
const extra: string[] = []
|
||||
if (existingDirs !== undefined) {
|
||||
const f = join(mkdtempSync(join(tmpdir(), 'difyctl-existing-')), 'existing.txt')
|
||||
writeFileSync(f, `${existingDirs.join('\n')}\n`)
|
||||
extra.push('--existing-dirs', f)
|
||||
}
|
||||
return [
|
||||
'index',
|
||||
'--current',
|
||||
currentArg,
|
||||
'--channel',
|
||||
'edge',
|
||||
'--version',
|
||||
build.version,
|
||||
B1.version,
|
||||
'--commit',
|
||||
build.commit,
|
||||
B1.commit,
|
||||
'--build-date',
|
||||
build.buildDate,
|
||||
B1.buildDate,
|
||||
...extra,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
)
|
||||
return {
|
||||
code: r.status ?? 1,
|
||||
index: (r.status === 0 ? JSON.parse(r.stdout) : null) 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' }
|
||||
const buildIndex = (currentContent: string | null, existingDirs?: string[]) =>
|
||||
JSON.parse(main(indexArgs(currentContent, existingDirs))) as IndexJson
|
||||
|
||||
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.each([
|
||||
['a missing current file (arg "-")', null],
|
||||
['an empty current file (first publish, curl wrote nothing)', ''],
|
||||
['a "-"-content current file (curl 404 fallback)', '-\n'],
|
||||
])('treats %s as a fresh ledger', (_label, content) => {
|
||||
const index = buildIndex(content)
|
||||
expect(index).toMatchObject({ schema: 1, channel: 'edge' })
|
||||
expect(index.builds).toEqual([{ ...B1, dir: B1.version }])
|
||||
})
|
||||
|
||||
it('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('dies on a current index that is not valid JSON', () => {
|
||||
expect(() => main(indexArgs('{not json'))).toThrow('not valid JSON')
|
||||
})
|
||||
|
||||
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)
|
||||
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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Vendored
-57
@@ -1,57 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user