name: Desktop Client Build on: push: branches: - main paths: - "apps/desktop-client/**" workflow_dispatch: inputs: platforms: description: "Build platforms: mac, win, or mac,win" required: true default: "mac,win" mac_arches: description: "macOS architectures: arm64, x64, or comma-separated. universal requires a macOS runner." required: true default: "arm64" win_arches: description: "Windows architectures: x64, arm64, ia32, or comma-separated" required: true default: "x64" version_prefix: description: "Client version prefix. Final version is .." required: true default: "0.1" version_patch: description: "Optional fixed patch number. Empty means auto-increment." required: false default: "" output_dir: description: "NAS output directory" required: true default: "/vol1/1000/share/gl-test" host: description: "NAS SSH host" required: true default: "192.168.100.19" port: description: "NAS SSH port" required: true default: "22" user: description: "NAS SSH user" required: true default: "admin" password_source: description: "NAS SSH password source" required: true default: "repository_secret" type: choice options: - repository_secret - workflow_input ssh_password: description: "NAS SSH password. Used only when password_source is workflow_input." required: false default: "" use_sudo: description: "Use sudo for NAS file operations" required: true default: "true" type: choice options: - "true" - "false" permissions: contents: read concurrency: group: desktop-client-build cancel-in-progress: false env: DEFAULT_DESKTOP_PLATFORMS: "mac,win" DEFAULT_MAC_ARCHES: "arm64" DEFAULT_WIN_ARCHES: "x64" DEFAULT_VERSION_PREFIX: "0.1" DEFAULT_NAS_OUTPUT_DIR: "/vol1/1000/share/gl-test" DEFAULT_NAS_HOST: "192.168.100.19" DEFAULT_NAS_PORT: "22" DEFAULT_NAS_USER: "admin" DEFAULT_NAS_USE_SUDO: "true" PNPM_VERSION: "10.33.0" jobs: resolve: name: Resolve Build Metadata runs-on: ubuntu-latest timeout-minutes: 15 outputs: build_mac: ${{ steps.meta.outputs.build_mac }} build_win: ${{ steps.meta.outputs.build_win }} client_version: ${{ steps.meta.outputs.client_version }} mac_arch_flags: ${{ steps.meta.outputs.mac_arch_flags }} nas_host: ${{ steps.meta.outputs.nas_host }} nas_port: ${{ steps.meta.outputs.nas_port }} nas_user: ${{ steps.meta.outputs.nas_user }} nas_use_sudo: ${{ steps.meta.outputs.nas_use_sudo }} output_dir: ${{ steps.meta.outputs.output_dir }} release_name: ${{ steps.meta.outputs.release_name }} win_arch_flags: ${{ steps.meta.outputs.win_arch_flags }} env: REQUESTED_PLATFORMS: ${{ inputs.platforms }} REQUESTED_MAC_ARCHES: ${{ inputs.mac_arches }} REQUESTED_WIN_ARCHES: ${{ inputs.win_arches }} REQUESTED_VERSION_PREFIX: ${{ inputs.version_prefix }} REQUESTED_VERSION_PATCH: ${{ inputs.version_patch }} REQUESTED_OUTPUT_DIR: ${{ inputs.output_dir }} REQUESTED_NAS_HOST: ${{ inputs.host }} REQUESTED_NAS_PORT: ${{ inputs.port }} REQUESTED_NAS_USER: ${{ inputs.user }} REQUESTED_NAS_USE_SUDO: ${{ inputs.use_sudo }} NAS_SSH_PASSWORD_SOURCE: ${{ inputs.password_source }} NAS_SSH_PASSWORD_INPUT: ${{ inputs.ssh_password }} NAS_SSH_PASSWORD_SECRET: ${{ secrets.NAS_SSH_PASSWORD }} steps: - name: Checkout uses: actions/checkout@v4 - name: Install SSH tools run: | set -eu apt-get update apt-get install -y openssh-client sshpass - name: Resolve version and targets id: meta run: | set -eu normalize_csv() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]' } arch_flags() { value="$(normalize_csv "$1")" allowed="$2" result="" if [ -z "${value}" ]; then echo "::error::Architecture list cannot be empty" exit 1 fi for arch in $(printf '%s' "${value}" | tr ',' ' '); do found=false for candidate in ${allowed}; do if [ "${arch}" = "${candidate}" ]; then found=true break fi done if [ "${found}" != "true" ]; then echo "::error::Unsupported architecture '${arch}'. Allowed: ${allowed}" exit 1 fi result="${result} --${arch}" done printf '%s' "${result# }" } platforms="$(normalize_csv "${REQUESTED_PLATFORMS:-${DEFAULT_DESKTOP_PLATFORMS}}")" build_mac=false build_win=false for platform in $(printf '%s' "${platforms}" | tr ',' ' '); do case "${platform}" in mac|macos) build_mac=true ;; win|windows) build_win=true ;; *) echo "::error::Unsupported platform '${platform}'. Use mac, win, or mac,win." exit 1 ;; esac done if [ "${build_mac}" != "true" ] && [ "${build_win}" != "true" ]; then echo "::error::At least one platform must be selected." exit 1 fi mac_arches="$(normalize_csv "${REQUESTED_MAC_ARCHES:-${DEFAULT_MAC_ARCHES}}")" case ",${mac_arches}," in *,universal,*) echo "::error::mac universal cannot be built on the current ubuntu-latest runner. Use mac_arches=arm64 or x64, or run this job on a macOS runner." exit 1 ;; esac mac_arch_flags="$(arch_flags "${mac_arches}" "x64 arm64")" win_arch_flags="$(arch_flags "${REQUESTED_WIN_ARCHES:-${DEFAULT_WIN_ARCHES}}" "x64 arm64 ia32")" version_prefix="${REQUESTED_VERSION_PREFIX:-${DEFAULT_VERSION_PREFIX}}" if ! printf '%s' "${version_prefix}" | grep -Eq '^[0-9]+\.[0-9]+$'; then echo "::error::version_prefix must look like 0.1" exit 1 fi output_dir="${REQUESTED_OUTPUT_DIR:-${DEFAULT_NAS_OUTPUT_DIR}}" output_dir="${output_dir%/}" case "${output_dir}" in /*) ;; *) echo "::error::output_dir must be an absolute NAS path" exit 1 ;; esac case "${output_dir}" in *[!A-Za-z0-9_./-]*) echo "::error::output_dir contains unsupported characters" exit 1 ;; esac nas_host="${REQUESTED_NAS_HOST:-${DEFAULT_NAS_HOST}}" nas_port="${REQUESTED_NAS_PORT:-${DEFAULT_NAS_PORT}}" nas_user="${REQUESTED_NAS_USER:-${DEFAULT_NAS_USER}}" nas_use_sudo="${REQUESTED_NAS_USE_SUDO:-${DEFAULT_NAS_USE_SUDO}}" case "${nas_port}" in *[!0-9]*|"") echo "::error::NAS SSH port must be numeric" exit 1 ;; esac case "${nas_host}" in *[!A-Za-z0-9_.:-]*|"") echo "::error::NAS host contains unsupported characters" exit 1 ;; esac case "${nas_user}" in *[!A-Za-z0-9_.@-]*|"") echo "::error::NAS user contains unsupported characters" exit 1 ;; esac case "${nas_use_sudo}" in true|false) ;; *) echo "::error::use_sudo must be true or false" exit 1 ;; esac if [ -n "${REQUESTED_VERSION_PATCH:-}" ]; then if ! printf '%s' "${REQUESTED_VERSION_PATCH}" | grep -Eq '^[0-9]+$'; then echo "::error::version_patch must be a non-negative integer" exit 1 fi version_patch="${REQUESTED_VERSION_PATCH}" else password_source="${NAS_SSH_PASSWORD_SOURCE:-repository_secret}" case "${password_source}" in repository_secret) nas_ssh_password="${NAS_SSH_PASSWORD_SECRET:-}" ;; workflow_input) nas_ssh_password="${NAS_SSH_PASSWORD_INPUT:-}" ;; *) echo "::error::password_source must be repository_secret or workflow_input" exit 1 ;; esac if [ -n "${nas_ssh_password}" ]; then echo "::add-mask::${nas_ssh_password}" fi if [ -z "${nas_ssh_password}" ]; then if [ "${password_source}" = "workflow_input" ]; then echo "::error::Missing workflow input ssh_password" else echo "::error::Missing repository secret NAS_SSH_PASSWORD" fi exit 1 fi mkdir -p ~/.ssh chmod 700 ~/.ssh ssh-keyscan -p "${nas_port}" "${nas_host}" >> ~/.ssh/known_hosts sudo_password_b64="$(printf '%s' "${nas_ssh_password}" | base64 | tr -d '\n')" remote_names="$( { printf 'OUTPUT_DIR=%s\n' "${output_dir}" printf 'USE_SUDO=%s\n' "${nas_use_sudo}" printf 'SUDO_PASSWORD_B64=%s\n' "${sudo_password_b64}" cat <<'REMOTE_SCRIPT' set -eu SUDO_PASSWORD="$(printf '%s' "${SUDO_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" unset SUDO_PASSWORD_B64 run_cmd() { if [ "${USE_SUDO}" = "true" ]; then printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" "$@" else "$@" fi } run_cmd mkdir -p "${OUTPUT_DIR}" run_cmd find "${OUTPUT_DIR}" -maxdepth 3 \( -type f -o -type d -o -type l \) -print 2>/dev/null | sed 's#.*/##' REMOTE_SCRIPT } | SSHPASS="${nas_ssh_password}" sshpass -e ssh \ -p "${nas_port}" \ -o BatchMode=no \ -o StrictHostKeyChecking=yes \ "${nas_user}@${nas_host}" \ "sh -s" )" prefix_regex="$(printf '%s' "${version_prefix}" | sed 's/\./\\./g')" remote_patch="$( printf '%s\n' "${remote_names}" \ | sed -n "s/.*${prefix_regex}\.\([0-9][0-9]*\).*/\1/p" \ | sort -n \ | tail -n1 )" if [ -n "${remote_patch}" ]; then version_patch=$((remote_patch + 1)) else version_patch=0 fi fi client_version="${version_prefix}.${version_patch}" release_name="desktop-client-${client_version}" { echo "build_mac=${build_mac}" echo "build_win=${build_win}" echo "client_version=${client_version}" echo "mac_arch_flags=${mac_arch_flags}" echo "nas_host=${nas_host}" echo "nas_port=${nas_port}" echo "nas_user=${nas_user}" echo "nas_use_sudo=${nas_use_sudo}" echo "output_dir=${output_dir}" echo "release_name=${release_name}" echo "win_arch_flags=${win_arch_flags}" } >> "${GITHUB_OUTPUT}" echo "Version: ${client_version}" echo "Platforms: mac=${build_mac}, win=${build_win}" echo "macOS arch flags: ${mac_arch_flags}" echo "Windows arch flags: ${win_arch_flags}" echo "Use sudo on NAS: ${nas_use_sudo}" echo "NAS output: ${nas_user}@${nas_host}:${output_dir}/${release_name}" build-client: name: Build Desktop Client needs: resolve runs-on: ubuntu-latest timeout-minutes: 120 env: BUILD_MAC: ${{ needs.resolve.outputs.build_mac }} BUILD_WIN: ${{ needs.resolve.outputs.build_win }} CLIENT_VERSION: ${{ needs.resolve.outputs.client_version }} MAC_ARCH_FLAGS: ${{ needs.resolve.outputs.mac_arch_flags }} WIN_ARCH_FLAGS: ${{ needs.resolve.outputs.win_arch_flags }} CSC_IDENTITY_AUTO_DISCOVERY: "false" steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" - name: Setup pnpm run: | set -eu corepack enable corepack prepare pnpm@${PNPM_VERSION} --activate - name: Install dependencies run: pnpm install --frozen-lockfile - name: Install Wine for Windows packaging if: needs.resolve.outputs.build_win == 'true' run: | set -eu if command -v wine >/dev/null 2>&1 && command -v xvfb-run >/dev/null 2>&1; then wine --version if dpkg --print-foreign-architectures | grep -qx i386 && dpkg -s wine32:i386 >/dev/null 2>&1; then exit 0 fi fi dpkg --add-architecture i386 apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ wine \ wine64 \ wine32:i386 \ xvfb wine --version - name: Build unsigned desktop packages working-directory: apps/desktop-client run: | set -eu unset CSC_LINK CSC_NAME CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID WIN_CSC_LINK WIN_CSC_KEY_PASSWORD export CSC_IDENTITY_AUTO_DISCOVERY=false node -e 'const fs = require("fs"); const p = "package.json"; const pkg = JSON.parse(fs.readFileSync(p, "utf8")); pkg.version = process.env.CLIENT_VERSION; fs.writeFileSync(p, `${JSON.stringify(pkg, null, 2)}\n`); console.log(`Desktop client version: ${pkg.version}`);' rm -rf release pnpm run build:obf if [ "${BUILD_MAC}" = "true" ]; then pnpm exec electron-builder --mac zip ${MAC_ARCH_FLAGS} --publish never -c.mac.target=zip -c.extraMetadata.version="${CLIENT_VERSION}" fi if [ "${BUILD_WIN}" = "true" ]; then xvfb-run -a --server-args="-screen 0 1024x768x24" \ pnpm exec electron-builder --win zip ${WIN_ARCH_FLAGS} --publish never -c.win.target=zip -c.extraMetadata.version="${CLIENT_VERSION}" fi - name: Collect desktop artifacts working-directory: apps/desktop-client run: | set -eu artifact_root="${RUNNER_TEMP}/desktop-client-artifacts" mkdir -p "${artifact_root}" if [ "${BUILD_MAC}" = "true" ]; then mkdir -p "${artifact_root}/mac" mac_zip_count="$(find release -maxdepth 1 -type f -name "*.zip" ! -iname "*win*" ! -iname "*windows*" | wc -l | tr -d ' ')" if [ "${mac_zip_count}" = "0" ]; then echo "::error::No macOS zip artifact found" find release -maxdepth 1 -type f -name "*.zip" -print exit 1 fi if [ "${mac_zip_count}" != "1" ]; then echo "::error::Expected exactly one macOS zip artifact, found ${mac_zip_count}." find release -maxdepth 1 -type f -name "*.zip" -print exit 1 fi mac_zip="$(find release -maxdepth 1 -type f -name "*.zip" ! -iname "*win*" ! -iname "*windows*" | head -n1)" cp -p "${mac_zip}" "${artifact_root}/mac/省心推-mac-${CLIENT_VERSION}.zip" fi if [ "${BUILD_WIN}" = "true" ]; then mkdir -p "${artifact_root}/win" win_zip_count="$(find release -maxdepth 1 -type f -name "*.zip" \( -iname "*win*" -o -iname "*windows*" \) | wc -l | tr -d ' ')" if [ "${win_zip_count}" = "0" ]; then echo "::error::No Windows zip artifact found" find release -maxdepth 1 -type f -name "*.zip" -print exit 1 fi if [ "${win_zip_count}" != "1" ]; then echo "::error::Expected exactly one Windows zip artifact, found ${win_zip_count}." find release -maxdepth 1 -type f -name "*.zip" -print exit 1 fi win_zip="$(find release -maxdepth 1 -type f -name "*.zip" \( -iname "*win*" -o -iname "*windows*" \) | head -n1)" cp -p "${win_zip}" "${artifact_root}/win/省心推-win-${CLIENT_VERSION}.zip" fi find "${artifact_root}" -maxdepth 3 -type f -print - name: Upload desktop artifacts uses: actions/upload-artifact@v3 with: name: desktop-client path: ${{ runner.temp }}/desktop-client-artifacts if-no-files-found: error retention-days: 14 publish-to-nas: name: Publish Client Artifacts to NAS needs: - resolve - build-client if: >- always() && needs.resolve.result == 'success' && needs.build-client.result == 'success' runs-on: ubuntu-latest timeout-minutes: 30 env: CLIENT_VERSION: ${{ needs.resolve.outputs.client_version }} NAS_HOST: ${{ needs.resolve.outputs.nas_host }} NAS_PORT: ${{ needs.resolve.outputs.nas_port }} NAS_USER: ${{ needs.resolve.outputs.nas_user }} NAS_USE_SUDO: ${{ needs.resolve.outputs.nas_use_sudo }} OUTPUT_DIR: ${{ needs.resolve.outputs.output_dir }} RELEASE_NAME: ${{ needs.resolve.outputs.release_name }} NAS_SSH_PASSWORD_SOURCE: ${{ inputs.password_source }} NAS_SSH_PASSWORD_INPUT: ${{ inputs.ssh_password }} NAS_SSH_PASSWORD_SECRET: ${{ secrets.NAS_SSH_PASSWORD }} steps: - name: Install SSH tools run: | set -eu apt-get update apt-get install -y openssh-client sshpass tar - name: Download client artifacts uses: actions/download-artifact@v3 with: name: desktop-client path: desktop-artifacts - name: Write manifest run: | set -eu artifact_count="$(find desktop-artifacts -type f | wc -l | tr -d ' ')" if [ "${artifact_count}" = "0" ]; then echo "::error::No downloaded artifacts found" exit 1 fi printf '%s\n' "${CLIENT_VERSION}" > desktop-artifacts/VERSION cat > desktop-artifacts/manifest.json <> ~/.ssh/known_hosts remote="${NAS_USER}@${NAS_HOST}" ssh_opts="-p ${NAS_PORT} -o BatchMode=no -o StrictHostKeyChecking=yes" scp_opts="-P ${NAS_PORT} -o BatchMode=no -o StrictHostKeyChecking=yes" remote_release_dir="${OUTPUT_DIR}/${RELEASE_NAME}" sudo_password_b64="$(printf '%s' "${nas_ssh_password}" | base64 | tr -d '\n')" archive_path="${RUNNER_TEMP:-/tmp}/${RELEASE_NAME}.tar.gz" remote_archive="/tmp/${RELEASE_NAME}-${GITHUB_RUN_ID:-manual}.tar.gz" { printf 'OUTPUT_DIR=%s\n' "${OUTPUT_DIR}" printf 'RELEASE_NAME=%s\n' "${RELEASE_NAME}" printf 'USE_SUDO=%s\n' "${NAS_USE_SUDO}" printf 'SUDO_PASSWORD_B64=%s\n' "${sudo_password_b64}" cat <<'REMOTE_SCRIPT' set -eu SUDO_PASSWORD="$(printf '%s' "${SUDO_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" unset SUDO_PASSWORD_B64 run_cmd() { if [ "${USE_SUDO}" = "true" ]; then printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" "$@" else "$@" fi } run_cmd mkdir -p "${OUTPUT_DIR}/${RELEASE_NAME}" REMOTE_SCRIPT } | SSHPASS="${nas_ssh_password}" sshpass -e ssh ${ssh_opts} "${remote}" "sh -s" tar -C desktop-artifacts -czf "${archive_path}" . SSHPASS="${nas_ssh_password}" sshpass -e scp ${scp_opts} "${archive_path}" "${remote}:${remote_archive}" { printf 'REMOTE_ARCHIVE=%s\n' "${remote_archive}" printf 'REMOTE_RELEASE_DIR=%s\n' "${remote_release_dir}" printf 'USE_SUDO=%s\n' "${NAS_USE_SUDO}" printf 'SUDO_PASSWORD_B64=%s\n' "${sudo_password_b64}" cat <<'REMOTE_SCRIPT' set -eu SUDO_PASSWORD="$(printf '%s' "${SUDO_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" unset SUDO_PASSWORD_B64 if [ "${USE_SUDO}" = "true" ]; then printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" tar -xzf "${REMOTE_ARCHIVE}" -C "${REMOTE_RELEASE_DIR}" rm -f "${REMOTE_ARCHIVE}" else tar -xzf "${REMOTE_ARCHIVE}" -C "${REMOTE_RELEASE_DIR}" rm -f "${REMOTE_ARCHIVE}" fi REMOTE_SCRIPT } | SSHPASS="${nas_ssh_password}" sshpass -e ssh ${ssh_opts} "${remote}" "sh -s" { printf 'OUTPUT_DIR=%s\n' "${OUTPUT_DIR}" printf 'RELEASE_NAME=%s\n' "${RELEASE_NAME}" printf 'USE_SUDO=%s\n' "${NAS_USE_SUDO}" printf 'SUDO_PASSWORD_B64=%s\n' "${sudo_password_b64}" cat <<'REMOTE_SCRIPT' set -eu SUDO_PASSWORD="$(printf '%s' "${SUDO_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" unset SUDO_PASSWORD_B64 if [ "${USE_SUDO}" = "true" ]; then tmp_file="$(mktemp)" printf '%s\n' "${RELEASE_NAME}" > "${tmp_file}" printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" cp "${tmp_file}" "${OUTPUT_DIR}/latest-desktop-client.txt" rm -f "${tmp_file}" else printf '%s\n' "${RELEASE_NAME}" > "${OUTPUT_DIR}/latest-desktop-client.txt" fi REMOTE_SCRIPT } | SSHPASS="${nas_ssh_password}" sshpass -e ssh ${ssh_opts} "${remote}" "sh -s" { printf 'OUTPUT_DIR=%s\n' "${OUTPUT_DIR}" printf 'CLIENT_VERSION=%s\n' "${CLIENT_VERSION}" printf 'USE_SUDO=%s\n' "${NAS_USE_SUDO}" printf 'SUDO_PASSWORD_B64=%s\n' "${sudo_password_b64}" cat <<'REMOTE_SCRIPT' set -eu SUDO_PASSWORD="$(printf '%s' "${SUDO_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" unset SUDO_PASSWORD_B64 run_cmd() { if [ "${USE_SUDO}" = "true" ]; then printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" "$@" else "$@" fi } versioned_paths_desc() { root="$1" pattern="$2" prefix="$3" suffix="$4" for candidate in "${root}"/${pattern}; do [ -e "${candidate}" ] || continue name="${candidate##*/}" version="${name#${prefix}}" if [ -n "${suffix}" ]; then version="${version%${suffix}}" fi case "${version}" in *[!0-9.]*|""|*.*.*.*) continue ;; esac major="$(printf '%s' "${version}" | cut -d. -f1)" minor="$(printf '%s' "${version}" | cut -d. -f2)" patch="$(printf '%s' "${version}" | cut -d. -f3)" case "${major}${minor}${patch}" in *[!0-9]*|"") continue ;; esac printf '%s.%s.%s %s\n' "${major}" "${minor}" "${patch}" "${candidate}" done | sort -t. -k1,1nr -k2,2nr -k3,3nr | awk '{print $2}' } keep_latest_two() { root="$1" pattern="$2" prefix="$3" suffix="$4" mode="$5" keep=0 for path in $(versioned_paths_desc "${root}" "${pattern}" "${prefix}" "${suffix}"); do keep=$((keep + 1)) if [ "${keep}" -gt 2 ]; then if [ "${mode}" = "dir" ]; then run_cmd rm -rf "${path}" else run_cmd rm -f "${path}" fi fi done } run_cmd rm -rf "${OUTPUT_DIR}/mac" "${OUTPUT_DIR}/win" keep_latest_two "${OUTPUT_DIR}" "desktop-client-*" "desktop-client-" "" "dir" REMOTE_SCRIPT } | SSHPASS="${nas_ssh_password}" sshpass -e ssh ${ssh_opts} "${remote}" "sh -s" echo "Uploaded desktop client ${CLIENT_VERSION} to ${remote}:${remote_release_dir}"