mirror of
https://github.com/langgenius/dify.git
synced 2026-06-10 18:24:09 +08:00
feat(cli): difyctl release pipeline + tokenless installers (#37036)
This commit is contained in:
parent
72c92fa60a
commit
759b4cbad3
178
.github/workflows/cli-release.yml
vendored
178
.github/workflows/cli-release.yml
vendored
@ -2,87 +2,165 @@ name: CLI Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'difyctl-v*'
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Dify release tag to attach difyctl assets to (blank = latest stable)
|
||||
required: false
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Dify release tag to attach difyctl assets to (blank = latest stable)
|
||||
required: false
|
||||
type: string
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
concurrency:
|
||||
group: cli-release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: difyctl-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: build standalone binaries (all targets)
|
||||
validate:
|
||||
name: validate manifest + resolve target Dify release
|
||||
runs-on: depot-ubuntu-24.04
|
||||
if: github.repository == 'langgenius/dify'
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ./cli
|
||||
outputs:
|
||||
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Export manifest to env
|
||||
run: node scripts/release-naming.mjs github-env >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate manifest
|
||||
run: scripts/release-validate-manifest.sh
|
||||
|
||||
- name: Resolve target Dify release
|
||||
id: resolve
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
if [ -n "$EVENT_TAG" ]; then
|
||||
tag="$EVENT_TAG"
|
||||
elif [ -n "$INPUT_TAG" ]; then
|
||||
tag="$INPUT_TAG"
|
||||
else
|
||||
tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)"
|
||||
fi
|
||||
if [ -z "$tag" ]; then
|
||||
echo "::error::could not resolve a target Dify release tag"
|
||||
exit 1
|
||||
fi
|
||||
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "::error::target Dify release ${tag} not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "dify_tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::target Dify release ${tag}"
|
||||
|
||||
- name: Compatibility check
|
||||
env:
|
||||
DIFY_TAG: ${{ steps.resolve.outputs.dify_tag }}
|
||||
run: node scripts/release-naming.mjs compat-check "$DIFY_TAG"
|
||||
|
||||
- name: Reject duplicate difyctl version
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${difyctlTag}" >/dev/null 2>&1; then
|
||||
echo "::error::difyctl ${version} already released (tag ${difyctlTag} exists); bump cli/package.json version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release:
|
||||
name: build + attach standalone binaries (all targets)
|
||||
needs: validate
|
||||
runs-on: depot-ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ./cli
|
||||
|
||||
env:
|
||||
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Enable cross-arch native prebuilds
|
||||
working-directory: ./
|
||||
run: cat cli/scripts/cross-arch.pnpm.yaml >> pnpm-workspace.yaml
|
||||
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Export manifest to env
|
||||
run: node scripts/release-naming.mjs github-env >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Read cli/package.json
|
||||
id: manifest
|
||||
run: |
|
||||
version=$(node -p "require('./package.json').version")
|
||||
channel=$(node -p "require('./package.json').difyctl.channel")
|
||||
minDify=$(node -p "require('./package.json').difyctl.compat.minDify")
|
||||
maxDify=$(node -p "require('./package.json').difyctl.compat.maxDify")
|
||||
{
|
||||
echo "version=$version"
|
||||
echo "channel=$channel"
|
||||
echo "minDify=$minDify"
|
||||
echo "maxDify=$maxDify"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate manifest
|
||||
run: scripts/release-validate-manifest.sh
|
||||
|
||||
- name: Install cross-arch native prebuilds
|
||||
# Re-installs node_modules with every @napi-rs/keyring platform variant
|
||||
# so `bun build --compile` can embed the right .node into each target.
|
||||
working-directory: ./
|
||||
run: NPM_CONFIG_USERCONFIG="$PWD/cli/scripts/cross-arch.npmrc" pnpm install --frozen-lockfile
|
||||
bun-version-file: cli/.bun-version
|
||||
|
||||
- name: Compile standalone binaries (all targets)
|
||||
env:
|
||||
CLI_VERSION: ${{ steps.manifest.outputs.version }}
|
||||
DIFYCTL_CHANNEL: ${{ steps.manifest.outputs.channel }}
|
||||
DIFYCTL_MIN_DIFY: ${{ steps.manifest.outputs.minDify }}
|
||||
DIFYCTL_MAX_DIFY: ${{ steps.manifest.outputs.maxDify }}
|
||||
run: |
|
||||
DIFYCTL_COMMIT="$(git rev-parse HEAD)" \
|
||||
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
|
||||
pnpm build:bin
|
||||
|
||||
- name: Generate sha256 checksum file
|
||||
env:
|
||||
CLI_VERSION: ${{ steps.manifest.outputs.version }}
|
||||
run: scripts/release-write-checksums.sh
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||
with:
|
||||
tag_name: difyctl-v${{ steps.manifest.outputs.version }}
|
||||
name: difyctl ${{ steps.manifest.outputs.version }}
|
||||
prerelease: ${{ steps.manifest.outputs.channel != 'stable' }}
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
cli/dist/bin/difyctl-v*
|
||||
- name: Attach difyctl assets to Dify release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release upload "$DIFY_TAG" dist/bin/${tagPrefix}* \
|
||||
--repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
- name: Prune stale difyctl assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
new_set="$(cd dist/bin && ls ${tagPrefix}*)"
|
||||
gh release view "$DIFY_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json assets --jq '.assets[].name' \
|
||||
| { grep -E "^${tagPrefix}" || true; } \
|
||||
| while IFS= read -r name; do
|
||||
if ! printf '%s\n' "$new_set" | grep -qxF -- "$name"; then
|
||||
echo "::notice::pruning stale asset ${name}"
|
||||
gh release delete-asset "$DIFY_TAG" "$name" \
|
||||
--repo "$GITHUB_REPOSITORY" --yes
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Create provenance tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ref="refs/tags/${difyctlTag}"
|
||||
sha="$(git rev-parse HEAD)"
|
||||
status="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/git/refs" \
|
||||
-f ref="$ref" -f sha="$sha" --silent --include 2>/dev/null \
|
||||
| awk 'NR==1 {print $2; exit}' || true)"
|
||||
case "$status" in
|
||||
201) echo "::notice::created ${ref}" ;;
|
||||
422) echo "::notice::tag ${ref} already exists; skipping (immutable)" ;;
|
||||
*) echo "::error::provenance tag ${ref} not created (HTTP ${status:-unknown})"; exit 1 ;;
|
||||
esac
|
||||
|
||||
4
.github/workflows/cli-tests.yml
vendored
4
.github/workflows/cli-tests.yml
vendored
@ -37,6 +37,10 @@ jobs:
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Validate release manifest
|
||||
if: matrix.os == 'depot-ubuntu-24.04'
|
||||
run: scripts/release-validate-manifest.sh
|
||||
|
||||
- name: CI pipeline (typecheck, lint, coverage, build)
|
||||
run: pnpm ci
|
||||
|
||||
|
||||
1
cli/.bun-version
Normal file
1
cli/.bun-version
Normal file
@ -0,0 +1 @@
|
||||
1.3.11
|
||||
@ -8,6 +8,18 @@
|
||||
"compat": {
|
||||
"minDify": "1.14.0",
|
||||
"maxDify": "1.15.0"
|
||||
},
|
||||
"release": {
|
||||
"tagPrefix": "difyctl-v",
|
||||
"binName": "difyctl",
|
||||
"checksumsSuffix": "-checksums.txt",
|
||||
"targets": [
|
||||
{ "id": "linux-x64", "bunTarget": "bun-linux-x64", "exe": false },
|
||||
{ "id": "linux-arm64", "bunTarget": "bun-linux-arm64", "exe": false },
|
||||
{ "id": "darwin-x64", "bunTarget": "bun-darwin-x64", "exe": false },
|
||||
{ "id": "darwin-arm64", "bunTarget": "bun-darwin-arm64", "exe": false },
|
||||
{ "id": "windows-x64", "bunTarget": "bun-windows-x64", "exe": true }
|
||||
]
|
||||
}
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
# Cross-arch keyring prebuilds for difyctl release builds.
|
||||
#
|
||||
# Pre-populates node_modules with @napi-rs/keyring native bindings for every
|
||||
# release target so `bun build --compile` can embed them. Use via:
|
||||
#
|
||||
# NPM_CONFIG_USERCONFIG=cli/scripts/cross-arch.npmrc pnpm install --force
|
||||
#
|
||||
# Do not set as a workspace default — it would bloat dev installs.
|
||||
supported-architectures-os[]=linux
|
||||
supported-architectures-os[]=darwin
|
||||
supported-architectures-os[]=win32
|
||||
supported-architectures-cpu[]=x64
|
||||
supported-architectures-cpu[]=arm64
|
||||
20
cli/scripts/cross-arch.pnpm.yaml
Normal file
20
cli/scripts/cross-arch.pnpm.yaml
Normal file
@ -0,0 +1,20 @@
|
||||
# Cross-arch keyring prebuilds for difyctl release builds.
|
||||
#
|
||||
# Appended onto pnpm-workspace.yaml in CI *before* the dependency install so a
|
||||
# single `pnpm install` pulls the @napi-rs/keyring native bindings for every
|
||||
# release target, letting `bun build --compile` embed the correct per-target
|
||||
# `.node` into each standalone binary.
|
||||
#
|
||||
# Kept out of the committed pnpm-workspace.yaml on purpose: setting it as a
|
||||
# workspace default would pull every foreign-arch prebuild into ordinary dev
|
||||
# installs. pnpm only honors supportedArchitectures from pnpm config files
|
||||
# (workspace yaml / .npmrc parsed as pnpm config), and only on the install that
|
||||
# first populates node_modules — a second pass is a no-op.
|
||||
supportedArchitectures:
|
||||
os:
|
||||
- linux
|
||||
- darwin
|
||||
- win32
|
||||
cpu:
|
||||
- x64
|
||||
- arm64
|
||||
@ -1,118 +1,173 @@
|
||||
#!/bin/sh
|
||||
# install-cli.sh — one-line difyctl installer from the latest GitHub Actions build.
|
||||
# install-cli.sh — one-line difyctl installer. difyctl ships as assets on Dify
|
||||
# GitHub Releases; this installs the build matching your Dify version.
|
||||
#
|
||||
# usage:
|
||||
# GH_TOKEN=<pat> curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-cli.sh | sh
|
||||
# curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-cli.sh | sh
|
||||
#
|
||||
# env: DIFYCTL_PREFIX (default $HOME/.local), DIFYCTL_REPO (default langgenius/dify),
|
||||
# DIFYCTL_BRANCH (default main),
|
||||
# GH_TOKEN/GITHUB_TOKEN (required — workflow artifact zip downloads need
|
||||
# auth even on public repos; minimum scope: actions:read).
|
||||
# requires: curl, uname, jq, unzip, sha256sum or shasum.
|
||||
|
||||
# env:
|
||||
# DIFY_VERSION Dify release tag to install difyctl from (e.g. 1.14.2). Primary key.
|
||||
# DIFYCTL_VERSION difyctl version pin (used only when DIFY_VERSION is unset).
|
||||
# DIFYCTL_PREFIX install dir (default $HOME/.local); binary -> $PREFIX/bin/difyctl
|
||||
# DIFYCTL_REPO release source repo (default langgenius/dify)
|
||||
# requires: curl, uname, sort -V, and sha256sum or shasum.
|
||||
set -eu
|
||||
|
||||
REPO="${DIFYCTL_REPO:-langgenius/dify}"
|
||||
BRANCH="${DIFYCTL_BRANCH:-main}"
|
||||
PREFIX="${DIFYCTL_PREFIX:-${HOME}/.local}"
|
||||
WORKFLOW_FILE="cli-release.yml"
|
||||
TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
|
||||
DIFY_VERSION="${DIFY_VERSION:-}"
|
||||
DIFYCTL_VERSION="${DIFYCTL_VERSION:-}"
|
||||
API="https://api.github.com/repos/${REPO}"
|
||||
DL="https://github.com/${REPO}/releases/download"
|
||||
|
||||
err() { printf '%s\n' "install-cli: $*" >&2; }
|
||||
die() { err "$*"; exit 1; }
|
||||
need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; }
|
||||
re_escape() { printf '%s' "$1" | sed 's/[][\\.^$*+?(){}|/]/\\&/g'; }
|
||||
|
||||
need curl
|
||||
need uname
|
||||
need jq
|
||||
need unzip
|
||||
detect_target() {
|
||||
case "$(uname -s)" in
|
||||
Linux*) _os=linux ;;
|
||||
Darwin*) _os=darwin ;;
|
||||
*) die "unsupported OS: $(uname -s) (use install.ps1 on Windows)" ;;
|
||||
esac
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) _arch=x64 ;;
|
||||
arm64|aarch64) _arch=arm64 ;;
|
||||
*) die "unsupported arch: $(uname -m)" ;;
|
||||
esac
|
||||
printf '%s-%s' "$_os" "$_arch"
|
||||
}
|
||||
|
||||
[ -n "$TOKEN" ] || die "GH_TOKEN (or GITHUB_TOKEN) is required — workflow artifact downloads need auth"
|
||||
# list_asset_names (reads release JSON on stdin) -> one difyctl asset name per line
|
||||
list_asset_names() {
|
||||
grep -oE '"name"[[:space:]]*:[[:space:]]*"difyctl-v[^"]*"' \
|
||||
| sed -E 's#.*"name"[[:space:]]*:[[:space:]]*"([^"]*)".*#\1#'
|
||||
}
|
||||
|
||||
gh_curl() { curl -fsSL -H "Authorization: Bearer ${TOKEN}" -H "Accept: application/vnd.github.v3+json" "$@"; }
|
||||
# pick_asset TARGET (reads release JSON on stdin) -> highest-semver matching asset name
|
||||
pick_asset() {
|
||||
_target=$(re_escape "$1")
|
||||
list_asset_names \
|
||||
| grep -E -- "-${_target}(\\.exe)?\$" \
|
||||
| grep -vE -- '-checksums\.txt$' \
|
||||
| sort -V | tail -1
|
||||
}
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
HASH="sha256sum"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
HASH="shasum -a 256"
|
||||
else
|
||||
die "need sha256sum or shasum"
|
||||
# asset_version ASSET_NAME TARGET -> difyctl version embedded in the name
|
||||
asset_version() {
|
||||
_target=$(re_escape "$2")
|
||||
printf '%s' "$1" | sed -E "s#^difyctl-v(.*)-${_target}(\\.exe)?\$#\\1#"
|
||||
}
|
||||
|
||||
# list_release_tags (reads /releases array JSON on stdin) -> tag per line, newest first
|
||||
list_release_tags() {
|
||||
grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||||
| sed -E 's#.*:[[:space:]]*"([^"]*)".*#\1#'
|
||||
}
|
||||
|
||||
fetch_json() {
|
||||
curl -fsSL -H "Accept: application/vnd.github+json" "$1"
|
||||
}
|
||||
|
||||
# find_release_for_difyctl WANT TARGET -> newest Dify tag whose assets host that difyctl build
|
||||
find_release_for_difyctl() {
|
||||
_want="$1"
|
||||
_target="$2"
|
||||
_raw=$(fetch_json "${API}/releases?per_page=100") \
|
||||
|| die "failed to query ${REPO} releases (network error or GitHub API rate limit)"
|
||||
_tags=$(printf '%s' "$_raw" | list_release_tags)
|
||||
for _t in $_tags; do
|
||||
_rel=$(fetch_json "${API}/releases/tags/${_t}") \
|
||||
|| { err "fetch failed for ${_t}, skipping"; continue; }
|
||||
_name=$(printf '%s' "$_rel" | pick_asset "$_target")
|
||||
[ -n "$_name" ] || continue
|
||||
if [ "$(asset_version "$_name" "$_target")" = "$_want" ]; then
|
||||
printf '%s' "$_t"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_release() {
|
||||
_target="$1"
|
||||
if [ -n "$DIFY_VERSION" ]; then
|
||||
REL=$(fetch_json "${API}/releases/tags/${DIFY_VERSION}") \
|
||||
|| die "Dify release ${DIFY_VERSION} not found"
|
||||
DIFY_TAG="$DIFY_VERSION"
|
||||
elif [ -n "$DIFYCTL_VERSION" ]; then
|
||||
DIFY_TAG=$(find_release_for_difyctl "$DIFYCTL_VERSION" "$_target") \
|
||||
|| die "difyctl ${DIFYCTL_VERSION} not found on any Dify release"
|
||||
REL=$(fetch_json "${API}/releases/tags/${DIFY_TAG}") \
|
||||
|| die "failed to fetch Dify release ${DIFY_TAG}"
|
||||
else
|
||||
REL=$(fetch_json "${API}/releases/latest") \
|
||||
|| die "failed to query latest Dify release (set DIFY_VERSION to pin one)"
|
||||
DIFY_TAG=$(printf '%s' "$REL" | list_release_tags | head -1)
|
||||
[ -n "$DIFY_TAG" ] || die "could not parse a tag from the latest Dify release"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
need curl
|
||||
need uname
|
||||
sort -V /dev/null >/dev/null 2>&1 || die "sort with -V support is required (install coreutils)"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
HASH="sha256sum"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
HASH="shasum -a 256"
|
||||
else
|
||||
die "need sha256sum or shasum"
|
||||
fi
|
||||
|
||||
target=$(detect_target)
|
||||
resolve_release "$target"
|
||||
|
||||
asset=$(printf '%s' "$REL" | pick_asset "$target")
|
||||
[ -n "$asset" ] || die "no difyctl published for Dify ${DIFY_TAG} (target ${target}); set DIFY_VERSION to a release that has one"
|
||||
version=$(asset_version "$asset" "$target")
|
||||
checksums="difyctl-v${version}-checksums.txt"
|
||||
base="${DL}/${DIFY_TAG}"
|
||||
|
||||
tmp=$(mktemp -d 2>/dev/null || mktemp -d -t difyctl-install)
|
||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
|
||||
printf 'downloading %s (Dify %s)...\n' "$asset" "$DIFY_TAG"
|
||||
curl -fsSL "${base}/${asset}" -o "${tmp}/${asset}" \
|
||||
|| die "download failed: ${base}/${asset}"
|
||||
curl -fsSL "${base}/${checksums}" -o "${tmp}/${checksums}" \
|
||||
|| die "checksum manifest download failed: ${base}/${checksums}"
|
||||
|
||||
_pattern=$(re_escape "$asset")
|
||||
_sumline=$(grep -E -- "[[:space:]]${_pattern}\$" "${tmp}/${checksums}") || true
|
||||
[ -n "$_sumline" ] || die "no checksum entry for ${asset}"
|
||||
(
|
||||
cd "$tmp"
|
||||
printf '%s\n' "$_sumline" | $HASH -c -
|
||||
) || die "checksum mismatch for ${asset}"
|
||||
|
||||
bin_dir="${PREFIX}/bin"
|
||||
mkdir -p "$bin_dir"
|
||||
target_bin="${bin_dir}/difyctl"
|
||||
cp "${tmp}/${asset}" "$target_bin"
|
||||
chmod +x "$target_bin"
|
||||
|
||||
printf '\ndifyctl v%s installed (from Dify %s): %s\n' "$version" "$DIFY_TAG" "$target_bin"
|
||||
|
||||
case ":${PATH}:" in
|
||||
*":${bin_dir}:"*)
|
||||
"$target_bin" version >/dev/null 2>&1 \
|
||||
&& printf 'verify: run "difyctl version"\n' \
|
||||
|| err "binary present but failed to execute; check ${target_bin}"
|
||||
;;
|
||||
*)
|
||||
printf '\n%s is not on your PATH. Add this to your shell profile:\n' "$bin_dir"
|
||||
printf ' export PATH="%s:$PATH"\n' "$bin_dir"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ "${DIFYCTL_INSTALL_LIB:-0}" != "1" ]; then
|
||||
main "$@"
|
||||
fi
|
||||
|
||||
case "$(uname -s)" in
|
||||
Linux*) os=linux ;;
|
||||
Darwin*) os=darwin ;;
|
||||
*) die "unsupported OS: $(uname -s) (use the Windows .exe directly)" ;;
|
||||
esac
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) arch=x64 ;;
|
||||
arm64|aarch64) arch=arm64 ;;
|
||||
*) die "unsupported arch: $(uname -m)" ;;
|
||||
esac
|
||||
|
||||
target="${os}-${arch}"
|
||||
|
||||
# 1. Find the latest successful workflow run on the branch
|
||||
api_url="https://api.github.com/repos/${REPO}/actions/workflows/${WORKFLOW_FILE}/runs?branch=${BRANCH}&status=success&per_page=1"
|
||||
run_id=$(gh_curl "$api_url" | jq -r '.workflow_runs[0].id')
|
||||
|
||||
if [ -z "$run_id" ] || [ "$run_id" = "null" ]; then
|
||||
die "could not find a successful workflow run for ${WORKFLOW_FILE} on branch ${BRANCH}"
|
||||
fi
|
||||
|
||||
# 2. Find the artifact from that run
|
||||
artifacts_url="https://api.github.com/repos/${REPO}/actions/runs/${run_id}/artifacts"
|
||||
artifact_info=$(gh_curl "$artifacts_url" | jq '.artifacts[0]')
|
||||
artifact_id=$(printf '%s' "$artifact_info" | jq -r '.id')
|
||||
artifact_name=$(printf '%s' "$artifact_info" | jq -r '.name')
|
||||
|
||||
if [ -z "$artifact_id" ] || [ "$artifact_id" = "null" ]; then
|
||||
die "could not find any artifacts for workflow run ${run_id}"
|
||||
fi
|
||||
|
||||
# 3. Download and unzip the artifact (one zip with all platform binaries + checksums)
|
||||
tmp=$(mktemp -d 2>/dev/null || mktemp -d -t difyctl-install)
|
||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
|
||||
download_url="https://api.github.com/repos/${REPO}/actions/artifacts/${artifact_id}/zip"
|
||||
printf 'downloading artifact %s (run %s)...\n' "$artifact_name" "$run_id"
|
||||
gh_curl -L "$download_url" -o "${tmp}/artifact.zip"
|
||||
unzip -q "${tmp}/artifact.zip" -d "${tmp}/artifact"
|
||||
|
||||
# 4. Locate the binary for this host + the checksum manifest
|
||||
asset_path=$(ls "${tmp}/artifact"/difyctl-v*-"${target}" 2>/dev/null | head -1)
|
||||
[ -n "$asset_path" ] || die "no binary matching target ${target} in artifact"
|
||||
asset=$(basename "$asset_path")
|
||||
cli_version=${asset#difyctl-v}
|
||||
cli_version=${cli_version%-${target}}
|
||||
checksums="difyctl-v${cli_version}-checksums.txt"
|
||||
|
||||
[ -f "${tmp}/artifact/${checksums}" ] || die "checksum file ${checksums} not found in artifact"
|
||||
|
||||
# 5. Verify checksum
|
||||
(
|
||||
cd "${tmp}/artifact"
|
||||
grep " ${asset}\$" "$checksums" | $HASH -c -
|
||||
) || die "checksum mismatch for ${asset}"
|
||||
|
||||
# 6. Install: copy binary to <prefix>/bin/difyctl and chmod +x
|
||||
bin_dir="${PREFIX}/bin"
|
||||
mkdir -p "$bin_dir"
|
||||
target_bin="${bin_dir}/difyctl"
|
||||
cp "${tmp}/artifact/${asset}" "$target_bin"
|
||||
chmod +x "$target_bin"
|
||||
|
||||
printf '\ndifyctl v%s installed: %s\n' "$cli_version" "$target_bin"
|
||||
|
||||
case ":${PATH}:" in
|
||||
*":${bin_dir}:"*)
|
||||
"$target_bin" version >/dev/null 2>&1 \
|
||||
&& printf 'verify: run "difyctl version"\n' \
|
||||
|| err "binary present but failed to execute; check ${target_bin}"
|
||||
;;
|
||||
*)
|
||||
printf '\n%s is not on your PATH. Add this to your shell profile:\n' "$bin_dir"
|
||||
printf ' export PATH="%s:$PATH"\n' "$bin_dir"
|
||||
;;
|
||||
esac
|
||||
|
||||
192
cli/scripts/install-cli.test.ts
Normal file
192
cli/scripts/install-cli.test.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
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.
|
||||
/* eslint-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')
|
||||
/* eslint-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 ?? '' }
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
115
cli/scripts/install.ps1
Normal file
115
cli/scripts/install.ps1
Normal file
@ -0,0 +1,115 @@
|
||||
# install.ps1 — one-line difyctl installer for Windows. difyctl ships as assets
|
||||
# on Dify GitHub Releases; this installs the build matching your Dify version.
|
||||
#
|
||||
# usage:
|
||||
# irm https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install.ps1 | iex
|
||||
#
|
||||
# env:
|
||||
# DIFY_VERSION Dify release tag to install difyctl from (e.g. 1.14.2). Primary key.
|
||||
# DIFYCTL_VERSION difyctl version pin (used only when DIFY_VERSION is unset).
|
||||
# DIFYCTL_PREFIX install dir (default $env:LOCALAPPDATA\difyctl)
|
||||
# DIFYCTL_REPO release source repo (default langgenius/dify)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repo = if ($env:DIFYCTL_REPO) { $env:DIFYCTL_REPO } else { 'langgenius/dify' }
|
||||
$difyVersion = $env:DIFY_VERSION
|
||||
$difyctlVersion = $env:DIFYCTL_VERSION
|
||||
$prefix = if ($env:DIFYCTL_PREFIX) { $env:DIFYCTL_PREFIX } else { Join-Path $env:LOCALAPPDATA 'difyctl' }
|
||||
$target = 'windows-x64'
|
||||
$apiBase = "https://api.github.com/repos/$repo"
|
||||
$dlBase = "https://github.com/$repo/releases/download"
|
||||
$headers = @{ Accept = 'application/vnd.github+json' }
|
||||
|
||||
function Get-AssetSemver([string]$Name) {
|
||||
if ($Name -notmatch '^difyctl-v(.+?)-windows-x64\.exe$') { return $null }
|
||||
$v = $Matches[1]
|
||||
$core = (($v -split '\+')[0] -split '-')[0]
|
||||
if ($core -notmatch '^\d+\.\d+\.\d+$') { return $null }
|
||||
$rc = if ($v -match '-rc\.(\d+)') { [int]$Matches[1] } else { [int]::MaxValue }
|
||||
return [pscustomobject]@{ Name = $Name; Version = $v; Core = [version]$core; Rc = $rc }
|
||||
}
|
||||
|
||||
function Select-Asset([object]$Release) {
|
||||
$Release.assets |
|
||||
ForEach-Object { Get-AssetSemver $_.name } |
|
||||
Where-Object { $_ } |
|
||||
Sort-Object Core, Rc |
|
||||
Select-Object -Last 1
|
||||
}
|
||||
|
||||
function Find-ReleaseForDifyctl([string]$Want) {
|
||||
$releases = Invoke-RestMethod -Uri "$apiBase/releases?per_page=100" -Headers $headers
|
||||
foreach ($rel in $releases) {
|
||||
$asset = Select-Asset $rel
|
||||
if ($asset -and $asset.Version -eq $Want) { return $rel }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-Release {
|
||||
if ($difyVersion) {
|
||||
try { return Invoke-RestMethod -Uri "$apiBase/releases/tags/$difyVersion" -Headers $headers }
|
||||
catch { throw "Dify release $difyVersion not found: $_" }
|
||||
}
|
||||
elseif ($difyctlVersion) {
|
||||
$release = Find-ReleaseForDifyctl $difyctlVersion
|
||||
if (-not $release) { throw "difyctl $difyctlVersion not found on any Dify release" }
|
||||
return $release
|
||||
}
|
||||
else {
|
||||
try { return Invoke-RestMethod -Uri "$apiBase/releases/latest" -Headers $headers }
|
||||
catch { throw "failed to query latest Dify release (set DIFY_VERSION to pin one): $_" }
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Main {
|
||||
$release = Resolve-Release
|
||||
$difyTag = $release.tag_name
|
||||
$asset = Select-Asset $release
|
||||
if (-not $asset) { throw "no difyctl published for Dify $difyTag (target $target); set DIFY_VERSION to a release that has one" }
|
||||
|
||||
$assetName = $asset.Name
|
||||
$ver = $asset.Version
|
||||
$checksums = "difyctl-v$ver-checksums.txt"
|
||||
$base = "$dlBase/$difyTag"
|
||||
|
||||
$tmp = Join-Path $env:TEMP ("difyctl-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
|
||||
try {
|
||||
Write-Host "downloading $assetName (Dify $difyTag)..."
|
||||
$assetPath = Join-Path $tmp $assetName
|
||||
$sumsPath = Join-Path $tmp $checksums
|
||||
Invoke-WebRequest -Uri "$base/$assetName" -OutFile $assetPath
|
||||
Invoke-WebRequest -Uri "$base/$checksums" -OutFile $sumsPath
|
||||
|
||||
$expected = (Get-Content $sumsPath |
|
||||
Where-Object { $_ -match '\s' + [regex]::Escape($assetName) + '$' } |
|
||||
ForEach-Object { ($_ -split '\s+')[0] } |
|
||||
Select-Object -First 1)
|
||||
if (-not $expected) { throw "no checksum entry for $assetName" }
|
||||
$actual = (Get-FileHash -Path $assetPath -Algorithm SHA256).Hash.ToLower()
|
||||
if ($actual -ne $expected.ToLower()) { throw "checksum mismatch for $assetName" }
|
||||
|
||||
$binDir = Join-Path $prefix 'bin'
|
||||
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
|
||||
$targetBin = Join-Path $binDir 'difyctl.exe'
|
||||
Copy-Item -Path $assetPath -Destination $targetBin -Force
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "difyctl v$ver installed (from Dify $difyTag): $targetBin"
|
||||
if (($env:PATH -split ';') -notcontains $binDir) {
|
||||
Write-Host ""
|
||||
Write-Host "$binDir is not on your PATH. Add it with:"
|
||||
Write-Host " [Environment]::SetEnvironmentVariable('PATH', `"$binDir;`$env:PATH`", 'User')"
|
||||
}
|
||||
else {
|
||||
Write-Host 'verify: run "difyctl version"'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
if ($env:DIFYCTL_INSTALL_LIB -ne '1') { Invoke-Main }
|
||||
182
cli/scripts/install.ps1.test.ts
Normal file
182
cli/scripts/install.ps1.test.ts
Normal file
@ -0,0 +1,182 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@ -6,9 +6,10 @@
|
||||
# / dist/ step needed.
|
||||
#
|
||||
# Prereqs:
|
||||
# - All @napi-rs/keyring native variants present in node_modules. Use
|
||||
# `NPM_CONFIG_USERCONFIG=cli/scripts/cross-arch.npmrc pnpm install --force`
|
||||
# to populate them.
|
||||
# - All @napi-rs/keyring native variants present in node_modules. Append
|
||||
# cli/scripts/cross-arch.pnpm.yaml onto pnpm-workspace.yaml before the
|
||||
# install that first populates node_modules so pnpm fetches every
|
||||
# foreign-arch prebuild (the CLI Release workflow does this).
|
||||
#
|
||||
# Env (all optional; defaults derived from cli/package.json + git):
|
||||
# CLI_VERSION — package.json `version`
|
||||
@ -33,6 +34,7 @@ entry="${cli_root}/bin/run.ts"
|
||||
out_dir="${cli_root}/dist/bin"
|
||||
|
||||
read_pkg() { node -p "require('${cli_root}/package.json').$1" 2>/dev/null; }
|
||||
naming() { node "${_dir}/release-naming.mjs" "$@"; }
|
||||
|
||||
CLI_VERSION="${CLI_VERSION:-$(read_pkg version)}"
|
||||
DIFYCTL_CHANNEL="${DIFYCTL_CHANNEL:-$(read_pkg difyctl.channel)}"
|
||||
@ -59,25 +61,10 @@ defines=(
|
||||
"--define" "__DIFYCTL_BUILD_DATE__=\"${DIFYCTL_BUILD_DATE}\""
|
||||
)
|
||||
|
||||
# Bun --target -> release asset suffix (asset name omits the bun- prefix
|
||||
# and uses Node-style platform names; .exe is appended for Windows).
|
||||
targets=(
|
||||
"bun-linux-x64:linux-x64"
|
||||
"bun-linux-arm64:linux-arm64"
|
||||
"bun-darwin-x64:darwin-x64"
|
||||
"bun-darwin-arm64:darwin-arm64"
|
||||
"bun-windows-x64:windows-x64"
|
||||
)
|
||||
|
||||
for spec in "${targets[@]}"; do
|
||||
bun_target="${spec%%:*}"
|
||||
asset_target="${spec##*:}"
|
||||
suffix=""
|
||||
case "$bun_target" in
|
||||
bun-windows-*) suffix=".exe" ;;
|
||||
esac
|
||||
|
||||
out="${out_dir}/difyctl-v${CLI_VERSION}-${asset_target}${suffix}"
|
||||
# Targets and asset names come from cli/package.json `difyctl.release` via
|
||||
# release-naming.mjs (single source of truth). Each line: bunTarget<TAB>id<TAB>exe.
|
||||
while IFS=$'\t' read -r bun_target asset_target _exe; do
|
||||
out="${out_dir}/$(naming asset "$CLI_VERSION" "$asset_target")"
|
||||
log::info "compiling ${asset_target} -> $(basename "$out")..."
|
||||
bun build "$entry" \
|
||||
--target="$bun_target" \
|
||||
@ -85,7 +72,7 @@ for spec in "${targets[@]}"; do
|
||||
--minify \
|
||||
"${defines[@]}" \
|
||||
--outfile="$out" >/dev/null
|
||||
done
|
||||
done < <(naming targets)
|
||||
|
||||
log::info "built $(find "$out_dir" -type f | wc -l | tr -d ' ') binaries:"
|
||||
ls -lh "$out_dir" >&2
|
||||
|
||||
231
cli/scripts/release-naming.mjs
Normal file
231
cli/scripts/release-naming.mjs
Normal file
@ -0,0 +1,231 @@
|
||||
#!/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.
|
||||
//
|
||||
// Subcommands:
|
||||
// tag <version> -> <tagPrefix><version>
|
||||
// asset <version> <id> -> <tagPrefix><version>-<id>[.exe]
|
||||
// checksums <version> -> <tagPrefix><version><checksumsSuffix>
|
||||
// tag-prefix -> <tagPrefix>
|
||||
// targets -> one line per target: "<bunTarget>\t<id>\t<0|1 exe>"
|
||||
// channels -> one channel name per line
|
||||
// prerelease <channel> -> "true" | "false"
|
||||
// github-env -> key=value lines (all fields CI needs) for $GITHUB_ENV
|
||||
// validate -> exit 1 if difyctl.release, version, or channel is malformed
|
||||
// compat-check <difyVer> -> exit 1 if difyVer outside compat.minDify..maxDify
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/
|
||||
const SEMVER_CORE_LEN = 3
|
||||
|
||||
// Channel registry — single source for which version forms are releasable and
|
||||
// resolvable. Each `versionForm` is pinned to exactly what the installers'
|
||||
// channel filters accept (stable = no prerelease; rc = -rc.N with nothing
|
||||
// trailing), so any version that passes `validate` is guaranteed resolvable at
|
||||
// install time. Extend by adding an entry: { name, prerelease, versionForm }.
|
||||
const CHANNELS = [
|
||||
{ name: 'stable', prerelease: false, versionForm: /^\d+\.\d+\.\d+(\+[0-9A-Z.-]+)?$/i },
|
||||
{ name: 'rc', prerelease: true, versionForm: /^\d+\.\d+\.\d+-rc\.\d+$/ },
|
||||
]
|
||||
|
||||
const channelByName = name => CHANNELS.find(c => c.name === name)
|
||||
const channelNames = () => CHANNELS.map(c => c.name).join(', ')
|
||||
|
||||
function parsePrecedence(v) {
|
||||
const s = String(v).replace(/^v/, '').replace(/\+.*$/, '')
|
||||
const i = s.indexOf('-')
|
||||
const core = i === -1 ? s : s.slice(0, i)
|
||||
const pre = i === -1 ? '' : s.slice(i + 1)
|
||||
return { nums: core.split('.').map(Number), pre }
|
||||
}
|
||||
|
||||
function 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)
|
||||
}
|
||||
|
||||
function loadPkg() {
|
||||
const pkgUrl = new URL('../package.json', import.meta.url)
|
||||
const pkg = JSON.parse(readFileSync(pkgUrl, 'utf8'))
|
||||
if (!pkg.difyctl?.release)
|
||||
die('cli/package.json missing difyctl.release')
|
||||
return {
|
||||
version: pkg.version,
|
||||
channel: pkg.difyctl.channel,
|
||||
compat: pkg.difyctl.compat ?? {},
|
||||
release: pkg.difyctl.release,
|
||||
}
|
||||
}
|
||||
|
||||
// Every field downstream CI needs, as `key=value` lines for $GITHUB_ENV. Each
|
||||
// job pipes this once into the environment, then references ${{ env.<field> }}
|
||||
// at use sites.
|
||||
function githubEnv() {
|
||||
const { version, channel, compat, release } = loadPkg()
|
||||
const fields = {
|
||||
version,
|
||||
channel,
|
||||
prerelease: channelByName(channel)?.prerelease ?? false,
|
||||
minDify: compat.minDify,
|
||||
maxDify: compat.maxDify,
|
||||
tagPrefix: release.tagPrefix,
|
||||
difyctlTag: `${release.tagPrefix}${version}`,
|
||||
}
|
||||
return Object.entries(fields).map(([k, v]) => `${k}=${v}`).join('\n')
|
||||
}
|
||||
|
||||
function requireVersion(version) {
|
||||
if (!version)
|
||||
die('version argument is required')
|
||||
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
|
||||
}
|
||||
|
||||
// Enforce that the version matches the form its declared channel can resolve.
|
||||
// Rejects e.g. channel=rc + 1.2.3-rc5 (no dot), channel=stable + 1.2.3-rc.1,
|
||||
// or any unknown channel — before a release that no installer could find ships.
|
||||
function validateVersionChannel(version, channel) {
|
||||
const problems = []
|
||||
if (typeof version !== 'string' || version.length === 0)
|
||||
return ['package.json version must be a non-empty string']
|
||||
const ch = channelByName(channel)
|
||||
if (!ch)
|
||||
return [`difyctl.channel ${JSON.stringify(channel)} is not a known channel (expected one of: ${channelNames()})`]
|
||||
if (!ch.versionForm.test(version))
|
||||
problems.push(`version "${version}" does not match the ${channel} channel form ${ch.versionForm}; an installer could not resolve it`)
|
||||
return problems
|
||||
}
|
||||
|
||||
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': {
|
||||
const { release } = loadPkg()
|
||||
return `${release.tagPrefix}${requireVersion(rest[0])}${release.checksumsSuffix}`
|
||||
}
|
||||
case 'tag-prefix':
|
||||
return loadPkg().release.tagPrefix
|
||||
case 'targets':
|
||||
return loadPkg().release.targets.map(t => `${t.bunTarget}\t${t.id}\t${t.exe ? 1 : 0}`).join('\n')
|
||||
case 'channels':
|
||||
return CHANNELS.map(c => c.name).join('\n')
|
||||
case 'github-env':
|
||||
return githubEnv()
|
||||
case 'compat-check': {
|
||||
const { compat } = loadPkg()
|
||||
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)
|
||||
die(`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`)
|
||||
return `compatible: Dify ${difyVersion} within ${compat.minDify}..${compat.maxDify}`
|
||||
}
|
||||
case 'prerelease': {
|
||||
const ch = channelByName(rest[0] ?? die('channel argument is required'))
|
||||
if (!ch)
|
||||
die(`unknown channel: ${rest[0]} (expected one of: ${channelNames()})`)
|
||||
return String(ch.prerelease)
|
||||
}
|
||||
case 'validate': {
|
||||
const { version, channel, release } = loadPkg()
|
||||
const problems = [...validateRelease(release), ...validateVersionChannel(version, channel)]
|
||||
if (problems.length > 0)
|
||||
die(`invalid difyctl release config:\n - ${problems.join('\n - ')}`)
|
||||
return `difyctl release valid: version=${version} channel=${channel} targets=${release.targets.length}`
|
||||
}
|
||||
default:
|
||||
die(`unknown subcommand: ${cmd ?? '(none)'}`)
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`${main(process.argv.slice(2))}\n`)
|
||||
75
cli/scripts/release-naming.test.ts
Normal file
75
cli/scripts/release-naming.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url))
|
||||
|
||||
function run(args: string[]): { code: number, stdout: string, stderr: string } {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' })
|
||||
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 (compat 1.14.0..1.15.0)', () => {
|
||||
it('accepts a version inside the window', () => {
|
||||
expect(run(['compat-check', '1.14.7']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive lower bound', () => {
|
||||
expect(run(['compat-check', '1.14.0']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive upper bound', () => {
|
||||
expect(run(['compat-check', '1.15.0']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts a v-prefixed tag', () => {
|
||||
expect(run(['compat-check', 'v1.14.2']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version below the lower bound', () => {
|
||||
expect(run(['compat-check', '1.13.9']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version above the upper bound', () => {
|
||||
expect(run(['compat-check', '1.15.1']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('treats a prerelease of the upper bound as in range (1.15.0-rc1 <= 1.15.0)', () => {
|
||||
expect(run(['compat-check', '1.15.0-rc1']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('treats a prerelease of the lower bound as below it (1.14.0-rc1 < 1.14.0)', () => {
|
||||
expect(run(['compat-check', '1.14.0-rc1']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata on the upper bound (1.15.0+build == 1.15.0)', () => {
|
||||
expect(run(['compat-check', '1.15.0+build123']).code).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata when out of range (1.15.1+build still rejected)', () => {
|
||||
expect(run(['compat-check', '1.15.1+build123']).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('requires a version argument', () => {
|
||||
expect(run(['compat-check']).code).not.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('release-naming github-env', () => {
|
||||
it('emits difyctlTag = tagPrefix + version', () => {
|
||||
const { stdout } = run(['github-env'])
|
||||
expect(stdout).toMatch(/^difyctlTag=difyctl-v0\.1\.0-rc\.1$/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'))
|
||||
})
|
||||
})
|
||||
@ -16,12 +16,8 @@ channel=$(node -p "require('./package.json').difyctl.channel")
|
||||
min_dify=$(node -p "require('./package.json').difyctl.compat.minDify")
|
||||
max_dify=$(node -p "require('./package.json').difyctl.compat.maxDify")
|
||||
|
||||
[[ "$version" =~ $SEMVER_RE ]] || die "invalid version: ${version}"
|
||||
|
||||
case "$channel" in
|
||||
rc|stable) ;;
|
||||
*) die "invalid difyctl.channel: ${channel} (expected rc | stable)" ;;
|
||||
esac
|
||||
# Version form (per channel) and channel validity are enforced by
|
||||
# release-naming.mjs validate below — the single source for those rules.
|
||||
|
||||
[[ "$min_dify" =~ $SEMVER_RE ]] || die "invalid difyctl.compat.minDify: ${min_dify}"
|
||||
[[ "$max_dify" =~ $SEMVER_RE ]] || die "invalid difyctl.compat.maxDify: ${max_dify}"
|
||||
@ -40,4 +36,6 @@ console.log(0)
|
||||
|
||||
[[ "$cmp" -le 0 ]] || die "minDify (${min_dify}) > maxDify (${max_dify})"
|
||||
|
||||
node "${_dir}/release-naming.mjs" validate >/dev/null
|
||||
|
||||
log::info "manifest valid: version=${version} channel=${channel} compat=${min_dify}..${max_dify}"
|
||||
|
||||
@ -10,11 +10,15 @@ _dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
source "${_dir}/lib/common.sh"
|
||||
|
||||
: "${CLI_VERSION:?CLI_VERSION is required}"
|
||||
naming() { node "${_dir}/release-naming.mjs" "$@"; }
|
||||
|
||||
CLI_VERSION="${CLI_VERSION:-$(node -p "require('$(cli::root)/package.json').version")}"
|
||||
[[ -n "$CLI_VERSION" && "$CLI_VERSION" != "undefined" ]] || die "CLI_VERSION could not be derived from package.json"
|
||||
|
||||
cd "$(cli::root)/dist/bin"
|
||||
|
||||
manifest="difyctl-v${CLI_VERSION}-checksums.txt"
|
||||
manifest="$(naming checksums "$CLI_VERSION")"
|
||||
asset_prefix="$(naming tag-prefix)${CLI_VERSION}-"
|
||||
> "$manifest"
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
@ -26,13 +30,13 @@ else
|
||||
fi
|
||||
|
||||
found=0
|
||||
for bin in difyctl-v"${CLI_VERSION}"-*; do
|
||||
for bin in "${asset_prefix}"*; do
|
||||
[[ -f "$bin" ]] || continue
|
||||
[[ "$bin" == "$manifest" ]] && continue
|
||||
$hash_cmd "$bin" >> "$manifest"
|
||||
found=$((found + 1))
|
||||
done
|
||||
|
||||
[[ "$found" -gt 0 ]] || die "no binaries matching difyctl-v${CLI_VERSION}-* in dist/bin/"
|
||||
[[ "$found" -gt 0 ]] || die "no binaries matching ${asset_prefix}* in dist/bin/"
|
||||
|
||||
log::info "wrote ${manifest} (${found} entries)"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user