diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cdeec4ca..11cba2a80 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -485,6 +485,7 @@ jobs: ${{ needs.prepare.outputs.is_dry_run == 'false' }} env: ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}" + IS_STABLE_RELEASE: "${{ needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }}" RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' run: |- set -euo pipefail @@ -504,36 +505,45 @@ jobs: upload_release_assets "releases/qwen-code/${RELEASE_TAG}" - upload_asset "dist/installation/install-qwen-standalone.sh" "installation/install-qwen-standalone.sh" - upload_asset "dist/installation/install-qwen-standalone.ps1" "installation/install-qwen-standalone.ps1" - upload_asset "dist/installation/install-qwen-standalone.bat" "installation/install-qwen-standalone.bat" - upload_asset "dist/installation/uninstall-qwen-standalone.sh" "installation/uninstall-qwen-standalone.sh" - upload_asset "dist/installation/uninstall-qwen-standalone.ps1" "installation/uninstall-qwen-standalone.ps1" - upload_asset "dist/installation/SHA256SUMS" "installation/SHA256SUMS" + if [[ "${IS_STABLE_RELEASE}" == "true" ]]; then + upload_asset "dist/installation/install-qwen-standalone.sh" "installation/install-qwen-standalone.sh" + upload_asset "dist/installation/install-qwen-standalone.ps1" "installation/install-qwen-standalone.ps1" + upload_asset "dist/installation/install-qwen-standalone.bat" "installation/install-qwen-standalone.bat" + upload_asset "dist/installation/uninstall-qwen-standalone.sh" "installation/uninstall-qwen-standalone.sh" + upload_asset "dist/installation/uninstall-qwen-standalone.ps1" "installation/uninstall-qwen-standalone.ps1" + upload_asset "dist/installation/SHA256SUMS" "installation/SHA256SUMS" + else + echo "Skipping hosted installation asset upload for prerelease ${RELEASE_TAG}." + fi - name: 'Verify Aliyun OSS Release Assets' if: |- ${{ needs.prepare.outputs.is_dry_run == 'false' }} env: ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}" + IS_STABLE_RELEASE: "${{ needs.prepare.outputs.is_nightly == 'false' && needs.prepare.outputs.is_preview == 'false' }}" RELEASE_TAG: '${{ needs.prepare.outputs.release_tag }}' run: |- set -euo pipefail npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}" - hosted_tmp_dir="$(mktemp -d)" - trap 'rm -rf "${hosted_tmp_dir}"' EXIT - for asset in install-qwen-standalone.sh install-qwen-standalone.ps1 install-qwen-standalone.bat uninstall-qwen-standalone.sh uninstall-qwen-standalone.ps1 SHA256SUMS; do - url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${asset}" - curl -fsSL "${url}" -o "${hosted_tmp_dir}/${asset}" - done - cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || { - echo "::error::Hosted installation SHA256SUMS does not match local dist/installation/SHA256SUMS" - diff -u "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || true - exit 1 - } - (cd "${hosted_tmp_dir}" && sha256sum -c SHA256SUMS) + if [[ "${IS_STABLE_RELEASE}" == "true" ]]; then + hosted_tmp_dir="$(mktemp -d)" + trap 'rm -rf "${hosted_tmp_dir}"' EXIT + for asset in install-qwen-standalone.sh install-qwen-standalone.ps1 install-qwen-standalone.bat uninstall-qwen-standalone.sh uninstall-qwen-standalone.ps1 SHA256SUMS; do + url="${ALIYUN_OSS_PUBLIC_BASE_URL}/installation/${asset}" + curl -fsSL "${url}" -o "${hosted_tmp_dir}/${asset}" + done + cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || { + echo "::error::Hosted installation SHA256SUMS does not match local dist/installation/SHA256SUMS" + diff -u "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS" || true + exit 1 + } + (cd "${hosted_tmp_dir}" && sha256sum -c SHA256SUMS) + else + echo "Skipping hosted installation asset verification for prerelease ${RELEASE_TAG}." + fi - name: 'Publish Aliyun OSS Latest VERSION' if: |- diff --git a/scripts/installation/install-qwen-standalone.bat b/scripts/installation/install-qwen-standalone.bat index 385be3bf0..f0b537f00 100644 --- a/scripts/installation/install-qwen-standalone.bat +++ b/scripts/installation/install-qwen-standalone.bat @@ -241,7 +241,7 @@ if /i "!METHOD!"=="standalone" ( call :InstallStandalone if !ERRORLEVEL! NEQ 0 exit /b !ERRORLEVEL! call :PrintFinalInstructions "!INSTALL_BIN_DIR!" - endlocal & set "PATH=!INSTALL_BIN_DIR!;%PATH%" + endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%" exit /b 0 ) @@ -257,7 +257,7 @@ call :InstallStandalone set "STANDALONE_STATUS=!ERRORLEVEL!" if !STANDALONE_STATUS! EQU 0 ( call :PrintFinalInstructions "!INSTALL_BIN_DIR!" - endlocal & set "PATH=!INSTALL_BIN_DIR!;%PATH%" + endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%" exit /b 0 ) @@ -906,6 +906,12 @@ if !ERRORLEVEL! NEQ 0 ( exit /b 1 ) +call :RestoreStaleInstallBackup +if !ERRORLEVEL! NEQ 0 ( + if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 + exit /b 1 +) + if exist "!NEW_INSTALL_DIR!" ( rmdir /S /Q "!NEW_INSTALL_DIR!" >nul 2>&1 if !ERRORLEVEL! NEQ 0 ( @@ -918,7 +924,7 @@ if exist "!OLD_INSTALL_DIR!" ( rmdir /S /Q "!OLD_INSTALL_DIR!" >nul 2>&1 if !ERRORLEVEL! NEQ 0 ( if exist "!TEMP_DIR!" rmdir /S /Q "!TEMP_DIR!" >nul 2>&1 - echo ERROR: Failed to remove stale backup directory: !OLD_INSTALL_DIR!. + echo ERROR: Failed to remove stale install backup: !OLD_INSTALL_DIR!. exit /b 1 ) ) @@ -1057,6 +1063,18 @@ if !ERRORLEVEL! NEQ 0 ( ) exit /b 0 +:RestoreStaleInstallBackup +if exist "!INSTALL_DIR!" exit /b 0 +if not exist "!OLD_INSTALL_DIR!" exit /b 0 +echo WARNING: Found previous install backup without an active install: !OLD_INSTALL_DIR! +echo WARNING: Restoring backup to !INSTALL_DIR! before continuing. +move /Y "!OLD_INSTALL_DIR!" "!INSTALL_DIR!" >nul +if !ERRORLEVEL! NEQ 0 ( + echo ERROR: Failed to restore previous install from !OLD_INSTALL_DIR!. + exit /b 1 +) +exit /b 0 + :RejectArchiveLinks set "QWEN_EXTRACT_DIR=%~1" powershell -NoProfile -ExecutionPolicy Bypass -Command "$item = Get-ChildItem -LiteralPath $env:QWEN_EXTRACT_DIR -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1; if ($item) { exit 1 }" diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 7c9f188c9..918fb9265 100755 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -811,6 +811,7 @@ verify_checksum() { validate_archive_entry_path() { local entry="$1" + entry="${entry//\\//}" while [[ "${entry}" == ./* ]]; do entry="${entry#./}" @@ -826,7 +827,7 @@ validate_archive_entry_path() { esac case "${entry}" in - ""|/*|..|../*|*/..|*/../*|*\\*) + ""|/*|..|../*|*/..|*/../*) log_error "Archive contains unsafe path: ${entry:-}" return 1 ;; @@ -923,6 +924,24 @@ ensure_managed_install_dir() { return 1 } +restore_stale_install_backup() { + local old_install_dir="$1" + local current_install_dir="$2" + + if [[ -e "${current_install_dir}" || ! -e "${old_install_dir}" ]]; then + return 0 + fi + + log_warning "Found previous install backup without an active install: ${old_install_dir}" + log_warning "Restoring backup to ${current_install_dir} before continuing." + if mv "${old_install_dir}" "${current_install_dir}"; then + return 0 + fi + + log_error "Failed to restore previous install from ${old_install_dir}." + return 1 +} + is_qwen_standalone_install_dir() { local install_dir="$1" local manifest_path="${install_dir}/manifest.json" @@ -1113,7 +1132,18 @@ install_standalone() { rm -rf "${temp_dir}" return 1 fi - rm -rf "${new_install_dir}" "${old_install_dir}" "${wrapper_tmp}" + if ! restore_stale_install_backup "${old_install_dir}" "${INSTALL_LIB_DIR}"; then + rm -rf "${temp_dir}" + return 1 + fi + if [[ -e "${old_install_dir}" ]]; then + rm -rf "${old_install_dir}" || { + rm -rf "${temp_dir}" + log_error "Failed to remove stale install backup: ${old_install_dir}" + return 1 + } + fi + rm -rf "${new_install_dir}" "${wrapper_tmp}" mv "${extract_dir}/qwen-code" "${new_install_dir}" if ! write_unix_wrapper "${wrapper_tmp}" "${INSTALL_LIB_DIR}/bin/qwen"; then diff --git a/scripts/installation/uninstall-qwen-standalone.ps1 b/scripts/installation/uninstall-qwen-standalone.ps1 index 4576ec461..a454c8407 100644 --- a/scripts/installation/uninstall-qwen-standalone.ps1 +++ b/scripts/installation/uninstall-qwen-standalone.ps1 @@ -320,9 +320,8 @@ $installDir = Get-QwenInstallDir $installBinDir = Get-QwenInstallBinDir $installWasManaged = Test-QwenStandaloneInstallDir -InstallDir $installDir -Remove-CurrentCmdPathShim - if ($installWasManaged) { + Remove-CurrentCmdPathShim Remove-Item -LiteralPath $installDir -Recurse -Force Write-Success "Removed $installDir" } elseif (Test-Path -LiteralPath $installDir) { diff --git a/scripts/installation/uninstall-qwen-standalone.sh b/scripts/installation/uninstall-qwen-standalone.sh index 114838e48..0f4d211f5 100755 --- a/scripts/installation/uninstall-qwen-standalone.sh +++ b/scripts/installation/uninstall-qwen-standalone.sh @@ -193,8 +193,14 @@ remove_shell_path_entry() { } awk -v marker="${marker}" ' - $0 == marker { skip_next = 1; next } - skip_next == 1 { skip_next = 0; next } + index($0, marker) { check_next = 1; next } + check_next == 1 { + check_next = 0 + if ($0 ~ /^[[:space:]]*export PATH=/ || + $0 ~ /^[[:space:]]*set -gx PATH /) { + next + } + } { print } ' "${rc_file}" > "${temp_file}" && mv "${temp_file}" "${rc_file}" || { rm -f "${temp_file}" diff --git a/scripts/tests/install-script.test.js b/scripts/tests/install-script.test.js index 18d21005f..47f9e32a2 100644 --- a/scripts/tests/install-script.test.js +++ b/scripts/tests/install-script.test.js @@ -143,6 +143,14 @@ describe('installation scripts', () => { expect(script).toContain('/latest/VERSION'); expect(script).toContain('resolve_aliyun_version_path()'); expect(script).toContain('retrying GitHub mirror'); + expect(script).toContain('entry="${entry//\\\\//}"'); + expect(script).toContain('restore_stale_install_backup()'); + expect(script).toContain( + 'restore_stale_install_backup "${old_install_dir}" "${INSTALL_LIB_DIR}"', + ); + expect(script).not.toContain( + 'rm -rf "${new_install_dir}" "${old_install_dir}" "${wrapper_tmp}"', + ); expect(script).not.toContain('-print -quit'); }); @@ -252,6 +260,15 @@ describe('installation scripts', () => { expect(script).toContain(':ResolveAliyunVersionPath'); expect(script).toContain(':UseGithubFallbackBaseUrl'); expect(script).toContain('retrying GitHub mirror'); + expect(script).toContain('endlocal & set "PATH=%INSTALL_BIN_DIR%;%PATH%"'); + expect(script).not.toContain( + 'endlocal & set "PATH=!INSTALL_BIN_DIR!;%PATH%"', + ); + expect(script).toContain(':RestoreStaleInstallBackup'); + expect(script).toContain('call :RestoreStaleInstallBackup'); + expect(script).not.toContain( + 'ERROR: Failed to remove stale backup directory', + ); expect(script).not.toContain('%RANDOM%'); }); @@ -1341,6 +1358,14 @@ describe('standalone release packaging', () => { expect(workflow.slice(publishLatestStepIndex)).toContain( 'releases/qwen-code/latest/VERSION', ); + const syncStep = workflow.slice(syncStepIndex, verifyStepIndex); + expect(syncStep).toContain('IS_STABLE_RELEASE'); + expect(syncStep).toContain( + 'if [[ "${IS_STABLE_RELEASE}" == "true" ]]; then', + ); + expect(syncStep).toContain( + 'Skipping hosted installation asset upload for prerelease', + ); expect(workflow).toContain('installation/install-qwen-standalone.sh'); expect(workflow).toContain('installation/install-qwen-standalone.bat'); expect(workflow).toContain('installation/install-qwen-standalone.ps1'); @@ -1356,6 +1381,14 @@ describe('standalone release packaging', () => { expect(workflow).not.toContain( 'npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/latest"', ); + const verifyStep = workflow.slice(verifyStepIndex, publishLatestStepIndex); + expect(verifyStep).toContain('IS_STABLE_RELEASE'); + expect(verifyStep).toContain( + 'if [[ "${IS_STABLE_RELEASE}" == "true" ]]; then', + ); + expect(verifyStep).toContain( + 'Skipping hosted installation asset verification for prerelease', + ); expect(workflow).toContain('hosted_tmp_dir="$(mktemp -d)"'); expect(workflow).toContain( 'cmp -s "dist/installation/SHA256SUMS" "${hosted_tmp_dir}/SHA256SUMS"', @@ -1421,6 +1454,12 @@ describe('standalone release packaging', () => { ); expect(uninstallPowerShellSource).toContain('QWEN_UNINSTALL_PURGE'); expect(uninstallPowerShellSource).toContain('Preserving'); + expect(uninstallPowerShellSource).toMatch( + /if \(\$installWasManaged\) \{\n\s+Remove-CurrentCmdPathShim\n\s+Remove-Item/, + ); + expect(uninstallPowerShellSource).not.toMatch( + /\$installWasManaged = Test-QwenStandaloneInstallDir[^\n]*\n\nRemove-CurrentCmdPathShim\n\nif \(\$installWasManaged\)/, + ); }); }); @@ -1748,6 +1787,46 @@ describe('Linux/macOS installer end-to-end', () => { } }); + itOnUnix( + 'removes only installer-owned shell rc PATH lines during uninstall', + () => { + const createdDist = ensureMinimalDist(); + const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-uninstall-test-')); + + try { + const archive = packageFakeStandalone(tmpDir); + const installRoot = path.join(tmpDir, 'install'); + const home = path.join(tmpDir, 'home'); + runUnixInstaller(archive, installRoot, home); + + const rcFile = path.join(home, '.zshrc'); + writeFileSync( + rcFile, + [ + 'before', + '# Added by qwen-code installer (multi-qwen shadow fix) ', + `export PATH='${installRoot}/bin':$PATH`, + 'middle', + '# Added by qwen-code installer (multi-qwen shadow fix)', + 'echo keep-me', + 'after', + ].join('\n') + '\n', + ); + + runUnixUninstaller(installRoot, home); + + expect(readScript(rcFile)).toBe( + ['before', 'middle', 'echo keep-me', 'after'].join('\n') + '\n', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + if (createdDist) { + rmSync('dist', { recursive: true, force: true }); + } + } + }, + ); + itOnUnix('shell-quotes custom install paths in the generated wrapper', () => { const createdDist = ensureMinimalDist(); const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-install-test-'));