diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 2aea3c9e0e..4d7de2dd2c 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -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 diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index 4498afa416..4e696430af 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -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 diff --git a/cli/.bun-version b/cli/.bun-version new file mode 100644 index 0000000000..17e63e7aff --- /dev/null +++ b/cli/.bun-version @@ -0,0 +1 @@ +1.3.11 diff --git a/cli/package.json b/cli/package.json index a582f6e010..6689aa80d8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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", diff --git a/cli/scripts/cross-arch.npmrc b/cli/scripts/cross-arch.npmrc deleted file mode 100644 index 246bb479db..0000000000 --- a/cli/scripts/cross-arch.npmrc +++ /dev/null @@ -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 diff --git a/cli/scripts/cross-arch.pnpm.yaml b/cli/scripts/cross-arch.pnpm.yaml new file mode 100644 index 0000000000..cb9960984d --- /dev/null +++ b/cli/scripts/cross-arch.pnpm.yaml @@ -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 diff --git a/cli/scripts/install-cli.sh b/cli/scripts/install-cli.sh index 955dcd5a9e..5bc2d3e93a 100755 --- a/cli/scripts/install-cli.sh +++ b/cli/scripts/install-cli.sh @@ -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= 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 /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 diff --git a/cli/scripts/install-cli.test.ts b/cli/scripts/install-cli.test.ts new file mode 100644 index 0000000000..d72b940df3 --- /dev/null +++ b/cli/scripts/install-cli.test.ts @@ -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__>), +// 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 = {}): { 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') + }) +}) diff --git a/cli/scripts/install.ps1 b/cli/scripts/install.ps1 new file mode 100644 index 0000000000..2f2f2f4320 --- /dev/null +++ b/cli/scripts/install.ps1 @@ -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 } diff --git a/cli/scripts/install.ps1.test.ts b/cli/scripts/install.ps1.test.ts new file mode 100644 index 0000000000..65ff13d858 --- /dev/null +++ b/cli/scripts/install.ps1.test.ts @@ -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 = {}): 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') + }) +}) diff --git a/cli/scripts/release-build.sh b/cli/scripts/release-build.sh index 500af0f728..b7f5f52792 100755 --- a/cli/scripts/release-build.sh +++ b/cli/scripts/release-build.sh @@ -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: bunTargetidexe. +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 diff --git a/cli/scripts/release-naming.mjs b/cli/scripts/release-naming.mjs new file mode 100644 index 0000000000..17ce210222 --- /dev/null +++ b/cli/scripts/release-naming.mjs @@ -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 -> +// asset -> -[.exe] +// checksums -> +// tag-prefix -> +// targets -> one line per target: "\t\t<0|1 exe>" +// channels -> one channel name per line +// prerelease -> "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 -> 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. }} +// 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`) diff --git a/cli/scripts/release-naming.test.ts b/cli/scripts/release-naming.test.ts new file mode 100644 index 0000000000..bf971db50e --- /dev/null +++ b/cli/scripts/release-naming.test.ts @@ -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')) + }) +}) diff --git a/cli/scripts/release-validate-manifest.sh b/cli/scripts/release-validate-manifest.sh index 44e88700fa..61f5325080 100755 --- a/cli/scripts/release-validate-manifest.sh +++ b/cli/scripts/release-validate-manifest.sh @@ -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}" diff --git a/cli/scripts/release-write-checksums.sh b/cli/scripts/release-write-checksums.sh index b106091324..b9e1cf6960 100755 --- a/cli/scripts/release-write-checksums.sh +++ b/cli/scripts/release-write-checksums.sh @@ -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)"