diff --git a/.azure-pipelines/AGENTS.md b/.azure-pipelines/AGENTS.md index efe346d2d4..4313d32579 100644 --- a/.azure-pipelines/AGENTS.md +++ b/.azure-pipelines/AGENTS.md @@ -89,7 +89,10 @@ adding exact filenames from `devops/scripts/compliance/binskim-runtime-exclusion - Some jobs also rely on **service connections** (`ame_esrp_connection` for ESRP, `AzureSubscription` for Key Vault) and **persisted checkout credentials** (`persistCredentials: true`, for checkout/fetch). Git tag/push and `gh` operations authenticate with the Key Vault `GH_TOKEN`, not the persisted checkout credentials — use these built-in mechanisms rather than embedding tokens. ## Packaging Roots (`packaging.yml`) + +- Existing native installer lifecycle jobs also exercise public metadata/download and piped-invocation paths using local HTTP-command fixtures backed by the selected pipeline archive, never GitHub's latest release. Linux/macOS share `packaging/common/validate_public_cli_download.sh`, which rejects `jq`/`gh` use; no JSON parser installation is needed on glibc, musl, or macOS. Windows uses scoped HTTP fixtures, and signed PowerShell validation runs the new public bootstrap as well as the bundled helper in isolated processes. No additional test matrix, Pester dependency, or GitHub Actions wiring is required. - `PackagingScriptsRoot = devops/scripts/packaging` +- Extension download-only helper checks run inside the same native CLI lifecycle jobs: Bash through the shared CLI fixture, and PowerShell through `common/test_public_extensions_download.ps1`. Both build fixture bundles from the downloaded pipeline extension packages and check pinned releases, failure preservation, and cleanup without live release downloads. - `PackagingOutRoot = devops/out` (with `common/`, `linux/`, `windows/`, `macos/` subfolders) - Default RIDs: `linux-x64`, `win-x64`, `osx-arm64`. - Pipeline jobs shell out to `devops/scripts/` (`ci/`, `compliance/`, `packaging/`, `publishing/`). When changing a build step, update the script there rather than embedding logic in YAML. diff --git a/devops/scripts/AGENTS.md b/devops/scripts/AGENTS.md index ca517b2980..7371a91fd3 100644 --- a/devops/scripts/AGENTS.md +++ b/devops/scripts/AGENTS.md @@ -32,6 +32,14 @@ output/staging path, update this guide in the same PR so it stays accurate. | `packaging/signing/` | ESRP input prep, repack, and verification of signed outputs, including collision-resistant macOS bundle ZIP mapping. | | `publishing/` | Release publishing helpers (`Update-ReleaseChangelog.ps1`, `Update-ChangedExtensionVersions.ps1`). | +## Installer Download Validation + +- The existing Linux/macOS lifecycle validators use `packaging/common/validate_public_cli_download.sh` for the public-download install, then continue their native runtime and extension checks. The shared fixture substitutes `curl` with local release metadata and the exact pipeline archive; it rejects `gh`/`jq` use and exercises missing/duplicate assets, invalid or truncated URLs, malformed metadata, HTTP failures, piped help, and truncated scripts. It preserves the existing local-archive safety coverage without requiring a JSON parser. +- Windows zip validation substitutes HTTP cmdlets in a child scope and installs the selected pipeline archive through `Invoke-Expression`; its existing offline and native lifecycle checks remain. +- `packaging/powershell/test_signed_azps_cmdlets.ps1` validates both the bundled helper and the public `install/azps-install.ps1` bootstrap in isolated PowerShell processes. Bootstrap HTTP fixtures copy the selected signed archive, exercise default and parameterized installation/import, and check error cleanup and existing-repository preservation. +- These checks run in the existing ADO validation jobs without Pester, GitHub authentication, or a live latest-release lookup. Keep shared Bash fixtures compatible with macOS Bash 3.2 and Alpine. +- The Bash fixture also validates `install/azcli-extensions-download.sh` against a bundle made from the pipeline's extension packages. Windows calls `packaging/common/test_public_extensions_download.ps1` for the PowerShell equivalent. These download-only helpers support a pinned release, save `azcli-extensions.tar.gz` in the working directory, preserve an existing bundle on failure, and never extract or install it. + ## Credential Scanner Suppressions `compliance/CredScanSuppressions.json` records file paths, scanner-provided SARIF diff --git a/devops/scripts/packaging/common/test_public_extensions_download.ps1 b/devops/scripts/packaging/common/test_public_extensions_download.ps1 new file mode 100644 index 0000000000..52de4e0f40 --- /dev/null +++ b/devops/scripts/packaging/common/test_public_extensions_download.ps1 @@ -0,0 +1,138 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ExtensionsDirectory +) + +$ErrorActionPreference = 'Stop' +$downloaderPath = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../../../install/azcli-extensions-download.ps1')) +$testRoot = Join-Path ([IO.Path]::GetTempPath()) "azclips-extension-download-test-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $testRoot | Out-Null +$fixture = @{ + Scenario = 'valid' + Tag = 'v-fixture.timestamp' + Archive = (Join-Path $testRoot 'pipeline-bundle.tar.gz') + Endpoint = '' +} +try { + tar -czf $fixture.Archive -C $ExtensionsDirectory . + if ($LASTEXITCODE -ne 0) { throw 'Could not create the extensions fixture from pipeline packages.' } + Push-Location $testRoot + try { + function Invoke-RestMethod { + [CmdletBinding()] + param([string]$Uri) + if ($Uri -notin @('https://api.github.com/repos/Azure/azclips/releases/latest', + "https://api.github.com/repos/Azure/azclips/releases/tags/$($fixture.Tag)")) { + throw "Unexpected metadata URL: $Uri" + } + $fixture.Endpoint = $Uri + if ($fixture.Scenario -eq 'metadata-failure') { throw 'Fixture HTTP 403 rate limit' } + $asset = @{ + name = 'azcli-extensions-0.0.0.tar.gz' + browser_download_url = "https://github.com/Azure/azclips/releases/download/$($fixture.Tag)/azcli-extensions-0.0.0.tar.gz" + } + if ($fixture.Scenario -eq 'invalid-url') { $asset.browser_download_url += '?download=1' } + if ($fixture.Scenario -eq 'wrong-repo') { $asset.browser_download_url = $asset.browser_download_url.Replace('/Azure/', '/Other/') } + if ($fixture.Scenario -eq 'unsafe-name') { $asset.name = '../azcli-extensions-0.0.0.tar.gz' } + $assets = @($asset, @{ name = 'unrelated.tar.gz'; browser_download_url = 'https://example.invalid/wrong' }) + if ($fixture.Scenario -eq 'missing') { $assets = @() } + if ($fixture.Scenario -eq 'duplicate') { $assets = @($asset, $asset) } + $tag = switch ($fixture.Scenario) { + 'malformed' { '' } + 'mismatched-release' { 'v-other' } + default { $fixture.Tag } + } + @{ tag_name = $tag; assets = $assets } + } + function Invoke-WebRequest { + [CmdletBinding()] + param([string]$Uri, [string]$OutFile) + if ($Uri -ne "https://github.com/Azure/azclips/releases/download/$($fixture.Tag)/azcli-extensions-0.0.0.tar.gz") { + throw "Unexpected asset URL: $Uri" + } + if ($fixture.Scenario -eq 'download-failure') { + Set-Content -LiteralPath $OutFile -Value 'partial download' + throw 'Fixture HTTP 503 download failure' + } + if ($fixture.Scenario -ne 'missing-download') { + Copy-Item -LiteralPath $fixture.Archive -Destination $OutFile + } + } + function gh { throw 'Download must not invoke gh.' } + function jq { throw 'Download must not invoke jq.' } + function Assert-DownloadCleanup { + if (Get-ChildItem -Force -Filter '.azcli-extensions-download*') { + throw 'Extensions downloader left staging files.' + } + } + Set-Content -Path azcli-extensions.tar.gz -Value 'existing bundle sentinel' + $before = (Get-FileHash azcli-extensions.tar.gz).Hash + foreach ($scenario in 'missing', 'duplicate', 'malformed', 'mismatched-release', 'invalid-url', 'wrong-repo', 'unsafe-name', 'metadata-failure', 'download-failure', 'missing-download') { + $fixture.Scenario = $scenario + $expected = switch ($scenario) { + 'malformed' { 'valid matching tag_name' } + 'mismatched-release' { 'valid matching tag_name' } + 'invalid-url' { 'invalid GitHub download URL' } + 'wrong-repo' { 'invalid GitHub download URL' } + 'metadata-failure' { 'Fixture HTTP 403' } + 'download-failure' { 'Fixture HTTP 503' } + 'missing-download' { 'Download did not produce' } + default { 'Expected exactly one' } + } + try { + if ($scenario -eq 'mismatched-release') { + & $downloaderPath -Release $fixture.Tag + } + else { + Get-Content -Raw $downloaderPath | Invoke-Expression + } + throw "FAIL: $scenario unexpectedly succeeded." + } + catch { + if ($_.Exception.Message -notlike "*$expected*") { throw } + } + if ((Get-FileHash azcli-extensions.tar.gz).Hash -ne $before) { + throw "FAIL: $scenario changed the existing bundle." + } + Assert-DownloadCleanup + } + $fixture.Scenario = 'valid' + $beforePath = $env:PATH + $ErrorActionPreference = 'Continue' + Get-Content -Raw $downloaderPath | Invoke-Expression + if ($ErrorActionPreference -ne 'Continue' -or $env:PATH -ne $beforePath -or + (Get-Variable metadata -Scope Local -ErrorAction SilentlyContinue)) { + throw 'Download helper leaked caller state.' + } + $ErrorActionPreference = 'Stop' + & $downloaderPath -Release $fixture.Tag + if ($fixture.Endpoint -notlike '*/tags/v-fixture.timestamp') { throw 'Download ignored the pinned release.' } + if ((Get-FileHash azcli-extensions.tar.gz).Hash -ne (Get-FileHash $fixture.Archive).Hash) { + throw 'Downloaded bundle differs from the pipeline packages.' + } + Assert-DownloadCleanup + if (@(Get-ChildItem -Force).Count -ne 2) { throw 'Helper extracted or created unexpected files.' } + try { + & $downloaderPath -Release '../invalid' + throw 'FAIL: Invalid release tag was accepted.' + } + catch { + if ($_.Exception.Message -notlike '*pattern*') { throw } + } + Remove-Item -LiteralPath azcli-extensions.tar.gz + New-Item -ItemType Directory -Path azcli-extensions.tar.gz | Out-Null + try { + Get-Content -Raw $downloaderPath | Invoke-Expression + throw 'FAIL: Directory destination was accepted.' + } + catch { + if ($_.Exception.Message -notlike '*non-file destination*') { throw } + } + Write-Host 'PASS: PowerShell download-only extensions helper, pinned release, scope isolation, failure preservation, and cleanup.' + } + finally { Pop-Location } +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction Stop +} diff --git a/devops/scripts/packaging/common/validate_public_cli_download.sh b/devops/scripts/packaging/common/validate_public_cli_download.sh new file mode 100644 index 0000000000..bbf23d9c56 --- /dev/null +++ b/devops/scripts/packaging/common/validate_public_cli_download.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Exercise the public download path with the native pipeline archive, not a live release. +repo_root="$1" +archive_path="$2" +extensions_dir="$3" +archive_name="$(basename "$archive_path")" +fixture_root="$(mktemp -d)" +trap 'rm -rf "$fixture_root"' EXIT +mkdir -p "$fixture_root/bin" "$fixture_root/tmp" +export FIXTURE_ROOT="$fixture_root" FIXTURE_ARCHIVE="$archive_path" +export FIXTURE_URL="https://github.com/${AZCLI_REPO:-Azure/azclips}/releases/download/v-fixture.timestamp/$archive_name" +export TMPDIR="$fixture_root/tmp" +unset GH_TOKEN GITHUB_TOKEN GH_ENTERPRISE_TOKEN GITHUB_ENTERPRISE_TOKEN + +# Limit PATH to standard utilities so jq, gh, and Python are genuinely unavailable. +for utility in bash basename cat chmod cksum cp dirname grep gzip ls mkdir mktemp mv rm sed tar touch tr uname; do + ln -s "$(command -v "$utility")" "$fixture_root/bin/$utility" +done +if command -v ldd >/dev/null; then + ln -s "$(command -v ldd)" "$fixture_root/bin/ldd" +fi + +if [[ "$archive_name" == azcli-lean-* ]]; then + other_profile="${archive_name/azcli-lean-/azcli-}" +else + other_profile="${archive_name/azcli-/azcli-lean-}" +fi +printf '{"assets":[{"browser_download_url" :\n "%s"}, {"browser_download_url":"%s"}, {"browser_download_url":"%s"}, {"other_url":"%s"}]}\n' \ + "$FIXTURE_URL" "${FIXTURE_URL%/*}/$other_profile" \ + "${FIXTURE_URL/github.com/github.com.evil.invalid}" "$FIXTURE_URL" > "$fixture_root/release.json" +cat > "$fixture_root/bin/curl" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +url="" +destination="" +while [[ $# -gt 0 ]]; do + case "$1" in + -o) destination="$2"; shift 2 ;; + https://*) url="$1"; shift ;; + *) shift ;; + esac +done +case "$url" in + "https://api.github.com/repos/${AZCLI_REPO:-Azure/azclips}/releases/latest"|"https://api.github.com/repos/Azure/azclips/releases/tags/v-fixture.timestamp") + printf '%s\n' "$url" > "$FIXTURE_ROOT/metadata-url" + case "${FIXTURE_CASE:-valid}" in + metadata-failure) echo "HTTP 403: fixture rate limit" >&2; exit 22 ;; + missing) printf '{"assets":[]}' > "$destination" ;; + duplicate) printf '{"assets":[{"browser_download_url":"%s"},{"browser_download_url":"%s"}]}' "$FIXTURE_URL" "$FIXTURE_URL" > "$destination" ;; + malformed) printf '{invalid' > "$destination" ;; + query) printf '{"browser_download_url":"%s?download=1"}' "$FIXTURE_URL" > "$destination" ;; + extra-path) printf '{"browser_download_url":"%s/extra"}' "$FIXTURE_URL" > "$destination" ;; + http) printf '{"browser_download_url":"%s"}' "${FIXTURE_URL/https:/http:}" > "$destination" ;; + wrong-repo) printf '{"browser_download_url":"%s"}' "${FIXTURE_URL/github.com\//github.com/other-}" > "$destination" ;; + missing-quote) printf '{"browser_download_url":"%s' "$FIXTURE_URL" > "$destination" ;; + other-field) printf '{"body":"%s"}' "$FIXTURE_URL" > "$destination" ;; + escaped-body) printf '{"body":"\\"browser_download_url\\":\\"%s\\""}' "$FIXTURE_URL" > "$destination" ;; + unsafe-filename) printf '{"browser_download_url":"%s"}' "${FIXTURE_URL/.tar.gz/%20.tar.gz}" > "$destination" ;; + wrong-platform) printf '{"browser_download_url":"%s"}' "${FIXTURE_URL/.tar.gz/-other.tar.gz}" > "$destination" ;; + compact) printf '{"assets":[{"browser_download_url":"%s"}]}' "$FIXTURE_URL" > "$destination" ;; + *) cp "$FIXTURE_ROOT/release.json" "$destination" ;; + esac + ;; + "$FIXTURE_URL") + if [[ "${FIXTURE_CASE:-valid}" == download-failure ]]; then + printf 'partial download' > "$destination" + echo "HTTP 503: fixture download failed" >&2 + exit 22 + fi + if [[ "${FIXTURE_CASE:-valid}" == missing-download ]]; then exit 0; fi + cp "$FIXTURE_ARCHIVE" "$destination" + touch "$FIXTURE_ROOT/downloaded" + ;; + *) echo "Unexpected download URL: $url" >&2; exit 1 ;; +esac +STUB +chmod +x "$fixture_root/bin/curl" +export PATH="$fixture_root/bin" +for unavailable in jq gh python python3; do + if command -v "$unavailable" >/dev/null; then + echo "FAIL: $unavailable must be unavailable during public installer validation." >&2 + exit 1 + fi +done + +before="$(cksum "$AZCLI_INSTALL_ROOT/az")" +for script in azcli-install.sh azcli-uninstall.sh; do + help="$(bash -s -- --help < "$repo_root/install/$script")" + grep -q 'Usage:' <<<"$help" || { echo "FAIL: Piped $script help is missing." >&2; exit 1; } + sed '$d' "$repo_root/install/$script" | bash + [[ "$(cksum "$AZCLI_INSTALL_ROOT/az")" == "$before" ]] || + { echo "FAIL: Truncated $script changed the installation." >&2; exit 1; } +done + +for scenario in missing duplicate malformed query extra-path http wrong-repo missing-quote other-field escaped-body unsafe-filename wrong-platform metadata-failure download-failure; do + export FIXTURE_CASE="$scenario" + case "$scenario" in + metadata-failure) expected='anonymous API rate limit' ;; + download-failure) expected='Could not download' ;; + *) expected='Expected exactly one' ;; + esac + if output="$(bash < "$repo_root/install/azcli-install.sh" 2>&1)"; then + echo "FAIL: $scenario unexpectedly succeeded." >&2 + exit 1 + fi + if ! grep -Fq "$expected" <<<"$output"; then + printf 'FAIL: %s did not report the expected error:\n%s\n' "$scenario" "$output" >&2 + exit 1 + fi + [[ "$(cksum "$AZCLI_INSTALL_ROOT/az")" == "$before" ]] || + { echo "FAIL: $scenario changed the existing installation." >&2; exit 1; } + [[ -z "$(ls -A "$TMPDIR")" ]] || + { echo "FAIL: $scenario left temporary download files." >&2; exit 1; } +done + +for scenario in valid compact; do + export FIXTURE_CASE="$scenario" + bash < "$repo_root/install/azcli-install.sh" +done +[[ -f "$fixture_root/downloaded" ]] || + { echo "FAIL: Public installation did not use only the fixture download." >&2; exit 1; } +echo "PASS: Public download selection, failure handling, piped help, and installation using the pipeline archive without jq, gh, or Python." + +export FIXTURE_ARCHIVE="$fixture_root/azcli-extensions-0.0.0.tar.gz" +export FIXTURE_URL="https://github.com/Azure/azclips/releases/download/v-fixture.timestamp/azcli-extensions-0.0.0.tar.gz" +tar -czf "$FIXTURE_ARCHIVE" -C "$extensions_dir" . +printf '{"assets":[{"browser_download_url" :\n "%s"}, {"other_url":"%s"}]}' \ + "$FIXTURE_URL" "$FIXTURE_URL" > "$fixture_root/release.json" +mkdir "$fixture_root/extensions-download" +cd "$fixture_root/extensions-download" +printf 'existing bundle sentinel\n' > azcli-extensions.tar.gz +before="$(cksum azcli-extensions.tar.gz)" +for scenario in missing duplicate malformed query extra-path http wrong-repo missing-quote other-field escaped-body unsafe-filename metadata-failure download-failure missing-download; do + export FIXTURE_CASE="$scenario" + case "$scenario" in + metadata-failure) expected='anonymous API rate limit' ;; + download-failure) expected='Could not download' ;; + missing-download) expected='Download did not produce' ;; + *) expected='Expected exactly one' ;; + esac + if output="$(bash < "$repo_root/install/azcli-extensions-download.sh" 2>&1)"; then + echo "FAIL: Extensions download $scenario unexpectedly succeeded." >&2; exit 1 + fi + grep -Fq "$expected" <<<"$output" || + { printf 'FAIL: Extensions download %s: %s\n' "$scenario" "$output" >&2; exit 1; } + [[ "$(cksum azcli-extensions.tar.gz)" == "$before" ]] || + { echo "FAIL: Extensions download $scenario changed the existing bundle." >&2; exit 1; } + if compgen -G '.azcli-extensions-download.*' >/dev/null; then + echo "FAIL: Extensions download $scenario left staging files." >&2; exit 1 + fi +done +for scenario in valid compact; do + export FIXTURE_CASE="$scenario" + bash < "$repo_root/install/azcli-extensions-download.sh" + [[ "$(cksum < azcli-extensions.tar.gz)" == "$(cksum < "$FIXTURE_ARCHIVE")" ]] || + { echo "FAIL: Downloaded extensions bundle differs from the pipeline packages." >&2; exit 1; } +done +bash -s -- --release v-fixture.timestamp < "$repo_root/install/azcli-extensions-download.sh" +grep -q '/tags/v-fixture.timestamp$' "$FIXTURE_ROOT/metadata-url" || + { echo "FAIL: Extensions download ignored the pinned release." >&2; exit 1; } +original_url="$FIXTURE_URL" +export FIXTURE_URL="${FIXTURE_URL/v-fixture.timestamp/v-other}" +if output="$(bash -s -- --release v-fixture.timestamp < "$repo_root/install/azcli-extensions-download.sh" 2>&1)"; then + echo "FAIL: Extensions download accepted a different release." >&2; exit 1 +fi +grep -q 'Expected exactly one' <<<"$output" +export FIXTURE_URL="$original_url" +if bash -s -- --release '../invalid' < "$repo_root/install/azcli-extensions-download.sh" > "$fixture_root/invalid-tag.log" 2>&1; then + echo "FAIL: Invalid release tag was accepted." >&2; exit 1 +fi +grep -q 'Invalid release tag' "$fixture_root/invalid-tag.log" +bash -s -- --help < "$repo_root/install/azcli-extensions-download.sh" | grep -q 'Usage:' +sed '$d' "$repo_root/install/azcli-extensions-download.sh" | bash +shopt -s dotglob nullglob +downloaded_files=(*) +[[ -f azcli-extensions.tar.gz && "${#downloaded_files[@]}" -eq 1 ]] || + { echo "FAIL: Download helper extracted files or left staging files." >&2; exit 1; } +mv azcli-extensions.tar.gz "$fixture_root/downloaded-bundle.tar.gz" +mkdir azcli-extensions.tar.gz +if output="$(bash < "$repo_root/install/azcli-extensions-download.sh" 2>&1)"; then + echo "FAIL: Download helper accepted a directory as its output file." >&2; exit 1 +fi +grep -q 'non-file destination' <<<"$output" +echo "PASS: Download-only extensions helper, pinned release, failure preservation, and cleanup without jq, gh, or Python." diff --git a/devops/scripts/packaging/linux/tarball/test_linux_tarball_bundled_plus_ext.sh b/devops/scripts/packaging/linux/tarball/test_linux_tarball_bundled_plus_ext.sh index abe587067a..58fa2aded0 100755 --- a/devops/scripts/packaging/linux/tarball/test_linux_tarball_bundled_plus_ext.sh +++ b/devops/scripts/packaging/linux/tarball/test_linux_tarball_bundled_plus_ext.sh @@ -273,9 +273,8 @@ echo " PASS: idempotent, guarded, and forced uninstall behavior succeeded" echo "classic az sentinel" >"$SIM_ROOT/classic-bin/az" chmod +x "$SIM_ROOT/classic-bin/az" -HOME="$SIM_ROOT/home" AZCLI_INSTALL_ROOT="$SIM_ROOT/install" "$REPO_ROOT/install/azcli-install.sh" \ - --package "$PACKAGE_PROFILE" \ - --archive "$CLI_TAR_PATH" +HOME="$SIM_ROOT/home" AZCLI_INSTALL_ROOT="$SIM_ROOT/install" AZCLI_PACKAGE="$PACKAGE_PROFILE" \ + bash "$PACKAGING_ROOT/common/validate_public_cli_download.sh" "$REPO_ROOT" "$CLI_TAR_PATH" "$EXT_PACK_DIR" AZ="$SIM_ROOT/install/az" @@ -586,7 +585,7 @@ fi echo "" echo "[17/17] Uninstall isolated azcli and preserve classic az" -AZCLI_INSTALL_ROOT="$SIM_ROOT/install" "$REPO_ROOT/install/azcli-uninstall.sh" +AZCLI_INSTALL_ROOT="$SIM_ROOT/install" bash < "$REPO_ROOT/install/azcli-uninstall.sh" if [[ -e "$SIM_ROOT/install" ]]; then echo " FAIL: azcli install root remains after uninstall" exit 1 diff --git a/devops/scripts/packaging/macos/tarball/test_macos_tarball_bundled_plus_ext.sh b/devops/scripts/packaging/macos/tarball/test_macos_tarball_bundled_plus_ext.sh index 3fda586f92..78f46f759e 100644 --- a/devops/scripts/packaging/macos/tarball/test_macos_tarball_bundled_plus_ext.sh +++ b/devops/scripts/packaging/macos/tarball/test_macos_tarball_bundled_plus_ext.sh @@ -284,9 +284,8 @@ echo " PASS: idempotent, guarded, and forced uninstall behavior succeeded" printf '#!/usr/bin/env sh\necho classic az sentinel\n' >"$SIM_ROOT/classic-bin/az" chmod +x "$SIM_ROOT/classic-bin/az" -HOME="$SIM_ROOT/home" AZCLIPS_HOME="$SIM_ROOT/home/.azclips" AZCLI_INSTALL_ROOT="$SIM_ROOT/install" "$REPO_ROOT/install/azcli-install.sh" \ - --package "$PACKAGE_PROFILE" \ - --archive "$CLI_TAR_PATH" +HOME="$SIM_ROOT/home" AZCLIPS_HOME="$SIM_ROOT/home/.azclips" AZCLI_INSTALL_ROOT="$SIM_ROOT/install" AZCLI_PACKAGE="$PACKAGE_PROFILE" \ + bash "$PACKAGING_ROOT/common/validate_public_cli_download.sh" "$REPO_ROOT" "$CLI_TAR_PATH" "$EXT_PACK_DIR" AZ="$SIM_ROOT/install/az" if [[ ! -x "$AZ" ]]; then @@ -618,7 +617,7 @@ fi echo "" echo "[17/17] Uninstall isolated azcli and preserve classic az" -HOME="$SIM_ROOT/home" AZCLI_INSTALL_ROOT="$SIM_ROOT/install" "$REPO_ROOT/install/azcli-uninstall.sh" +HOME="$SIM_ROOT/home" AZCLI_INSTALL_ROOT="$SIM_ROOT/install" bash < "$REPO_ROOT/install/azcli-uninstall.sh" if [[ -e "$SIM_ROOT/install" ]]; then echo " FAIL: azcli install root remains after uninstall" exit 1 diff --git a/devops/scripts/packaging/powershell/test_signed_azps_cmdlets.ps1 b/devops/scripts/packaging/powershell/test_signed_azps_cmdlets.ps1 index 607424f965..ccf1b89c9d 100644 --- a/devops/scripts/packaging/powershell/test_signed_azps_cmdlets.ps1 +++ b/devops/scripts/packaging/powershell/test_signed_azps_cmdlets.ps1 @@ -227,6 +227,131 @@ Write-Host "Imported signed archive module: $ModuleName $ExpectedVersion" ) Assert-NumericVersionInference -InstallerPath $installerPath -FixtureRoot $fixtureRoot + + $bootstrapPath = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../../../install/azps-install.ps1')) + $bootstrapValidationScript = Join-Path $testRoot 'ValidatePublicBootstrap.ps1' + @' +param($BootstrapPath, $ArchivePath, $ModuleName, $ExpectedVersion) +$ErrorActionPreference = 'Stop' +Import-Module PowerShellGet -ErrorAction Stop +$fixture = @{ Scenario = 'valid'; Stage = $null; Installed = $null } + +function gh { throw 'Public installation must not invoke gh.' } +function Invoke-RestMethod { + [CmdletBinding()] + param([string]$Uri) + if ($Uri -ne 'https://api.github.com/repos/Azure/azclips/releases/latest') { throw "Unexpected metadata URL: $Uri" } + if ($fixture.Scenario -eq 'metadata-failure') { throw 'Fixture HTTP 403 rate limit' } + $asset = @{ name = (Split-Path -Leaf $ArchivePath); browser_download_url = 'https://example.invalid/pipeline-archive' } + $assets = @($asset) + if ($fixture.Scenario -eq 'missing') { $assets = @() } + if ($fixture.Scenario -eq 'duplicate') { $assets = @($asset, $asset) } + if ($fixture.Scenario -eq 'invalid-url') { $asset.browser_download_url = 'http://example.invalid/pipeline-archive' } + $tag = if ($fixture.Scenario -eq 'malformed') { '' } else { 'v-fixture.timestamp' } + @{ tag_name = $tag; assets = $assets } +} +function Invoke-WebRequest { + [CmdletBinding()] + param([string]$Uri, [string]$OutFile) + if ($Uri -ne 'https://example.invalid/pipeline-archive') { throw "Wrong asset selected: $Uri" } + $fixture.Stage = Split-Path $OutFile + if ($fixture.Scenario -eq 'download-failure') { throw 'Fixture HTTP 503 download failure' } + Copy-Item -LiteralPath $ArchivePath -Destination $OutFile +} +function Install-Module { + [CmdletBinding()] + param($Name, $RequiredVersion, $Repository, $Scope, [switch]$Force, [switch]$AllowClobber) + $fixture.Installed = @{ Name = $Name; Version = $RequiredVersion; Repository = $Repository; Scope = $Scope } + if ($fixture.Scenario -eq 'install-failure') { throw 'Fixture installation failed' } + PowerShellGet\Install-Module @PSBoundParameters +} +function Assert-Cleanup { + if ($fixture.Stage -and (Test-Path -LiteralPath $fixture.Stage)) { throw 'Bootstrap left temporary files.' } + if ($fixture.Installed -and (Get-PSRepository -Name $fixture.Installed.Repository -ErrorAction SilentlyContinue)) { + throw 'Bootstrap left its temporary repository.' + } +} + +$repositoryName = "BootstrapFixture$PID" +& $BootstrapPath -ModuleName $ModuleName -RequiredVersion $ExpectedVersion -Scope CurrentUser -RepositoryName $repositoryName +if ($fixture.Installed.Name -ne $ModuleName -or $fixture.Installed.Version -ne $ExpectedVersion -or + $fixture.Installed.Repository -ne $repositoryName -or $fixture.Installed.Scope -ne 'CurrentUser') { + throw 'Bootstrap did not forward module/version/repository/scope options.' +} +Assert-Cleanup +Import-Module -Name $ModuleName -RequiredVersion $ExpectedVersion -ErrorAction Stop + +$beforePath = $env:PATH +$ErrorActionPreference = 'Continue' +Get-Content -Raw $BootstrapPath | Invoke-Expression +if ($ErrorActionPreference -ne 'Continue' -or $env:PATH -ne $beforePath -or + (Get-Variable options -Scope Local -ErrorAction SilentlyContinue)) { + throw 'Piped bootstrap leaked state into the caller.' +} +$ErrorActionPreference = 'Stop' +if ($fixture.Installed.Name -ne 'Az' -or $fixture.Installed.Scope -ne 'CurrentUser') { + throw 'Piped bootstrap did not install the default Az rollup.' +} +Assert-Cleanup +Import-Module Az -ErrorAction Stop + +foreach ($scenario in 'missing', 'duplicate', 'malformed', 'invalid-url', 'metadata-failure', 'download-failure', 'install-failure') { + $fixture.Scenario = $scenario + $fixture.Stage = $null + $fixture.Installed = $null + $expected = switch ($scenario) { + 'missing' { 'Expected exactly one' } + 'duplicate' { 'Expected exactly one' } + 'malformed' { 'valid tag_name' } + 'invalid-url' { 'HTTPS download URL' } + 'metadata-failure' { 'Fixture HTTP 403' } + 'download-failure' { 'Fixture HTTP 503' } + 'install-failure' { 'Fixture installation failed' } + } + try { + if ($scenario -eq 'install-failure') { + # The proxy throws before writing to AllUsers; only forwarding is tested. + & $BootstrapPath -ModuleName $ModuleName -Scope AllUsers + } + else { + Get-Content -Raw $BootstrapPath | Invoke-Expression + } + throw "FAIL: $scenario unexpectedly succeeded." + } + catch { + if ($_.Exception.Message -notlike "*$expected*") { throw } + } + Assert-Cleanup + if ($scenario -eq 'install-failure' -and $fixture.Installed.Scope -ne 'AllUsers') { + throw 'Bootstrap did not forward the non-default scope.' + } +} + +Register-PSRepository -Name $repositoryName -SourceLocation (Split-Path $ArchivePath) -InstallationPolicy Trusted +try { + try { + & $BootstrapPath -RepositoryName $repositoryName + throw 'FAIL: Bootstrap replaced an existing repository.' + } + catch { + if ($_.Exception.Message -notlike '*already exists*') { throw } + } + if (-not (Get-PSRepository -Name $repositoryName -ErrorAction SilentlyContinue)) { + throw 'Bootstrap removed an existing repository.' + } +} +finally { + Unregister-PSRepository -Name $repositoryName +} +Write-Host 'Validated public PowerShell bootstrap: pipeline archive installation/import, options, piped scope, and failure cleanup.' +'@ | Set-Content -Path $bootstrapValidationScript + Invoke-IsolatedPowerShell -PwshPath $PwshPath -HomeRoot $homeRoot -ModuleRoot $moduleRoot -Arguments @( + '-NoLogo', '-NoProfile', '-File', $bootstrapValidationScript, + '-BootstrapPath', $bootstrapPath, + '-ArchivePath', $resolvedArchivePath, + '-ModuleName', $candidate.Name, + '-ExpectedVersion', $candidate.Version.ToString() + ) Write-Host "Validated signed PowerShell installer lifecycle: $($candidate.Name) $($candidate.Version) from $resolvedArchivePath" } finally diff --git a/devops/scripts/packaging/windows/zip/test_windows_zip_bundled_plus_ext.ps1 b/devops/scripts/packaging/windows/zip/test_windows_zip_bundled_plus_ext.ps1 index 1ce6c60380..4e9876ec96 100644 --- a/devops/scripts/packaging/windows/zip/test_windows_zip_bundled_plus_ext.ps1 +++ b/devops/scripts/packaging/windows/zip/test_windows_zip_bundled_plus_ext.ps1 @@ -89,6 +89,77 @@ function Assert-UninstallFailure { } } +function Assert-PublicInstallerDownload { + $fixture = @{ Scenario = 'valid'; DownloadRoot = $null; Downloaded = $false } + & { + function gh { throw 'Public installation must not invoke gh.' } + function Invoke-RestMethod { + [CmdletBinding()] + param([string]$Uri) + if ($Uri -notlike 'https://api.github.com/repos/*/releases/latest') { + throw "Unexpected metadata URL: $Uri" + } + if ($fixture.Scenario -eq 'metadata-failure') { throw 'Fixture HTTP 403 rate limit' } + $asset = @{ name = $archiveName; browser_download_url = 'https://example.invalid/pipeline-archive' } + $assets = @($asset, @{ name = "unrelated-$archiveName"; browser_download_url = 'https://example.invalid/wrong-asset' }) + if ($fixture.Scenario -eq 'missing') { $assets = @() } + if ($fixture.Scenario -eq 'duplicate') { $assets = @($asset, $asset) } + $tag = if ($fixture.Scenario -eq 'malformed') { '' } else { 'v-fixture.timestamp' } + @{ tag_name = $tag; assets = $assets } + } + function Invoke-WebRequest { + [CmdletBinding()] + param([string]$Uri, [string]$OutFile) + if ($Uri -ne 'https://example.invalid/pipeline-archive') { throw "Wrong asset selected: $Uri" } + $fixture.DownloadRoot = Split-Path $OutFile + if ($fixture.Scenario -eq 'download-failure') { throw 'Fixture HTTP 503 download failure' } + Copy-Item -LiteralPath $CliZipPath -Destination $OutFile + $fixture.Downloaded = $true + } + + $before = (Get-FileHash -LiteralPath (Join-Path $installRoot 'az.exe')).Hash + foreach ($scenario in 'missing', 'duplicate', 'malformed', 'metadata-failure', 'download-failure') { + $fixture.Scenario = $scenario + $expected = switch ($scenario) { + 'missing' { 'Expected exactly one' } + 'duplicate' { 'Expected exactly one' } + 'malformed' { 'valid tag_name' } + 'metadata-failure' { 'Fixture HTTP 403' } + 'download-failure' { 'Fixture HTTP 503' } + } + Assert-InstallFailure -Description "public download $scenario" -ExpectedPattern $expected -Arguments @{ Package = $packageProfile } + if ((Get-FileHash -LiteralPath (Join-Path $installRoot 'az.exe')).Hash -ne $before) { + throw "FAIL: $scenario changed the existing installation." + } + if ($fixture.DownloadRoot -and (Test-Path -LiteralPath $fixture.DownloadRoot)) { + throw "FAIL: $scenario left temporary download files." + } + } + + $originalPackage = $env:AZCLI_PACKAGE + try { + $env:AZCLI_PACKAGE = $packageProfile + $fixture.Scenario = 'valid' + $beforePath = $env:PATH + $ErrorActionPreference = 'Continue' + Get-Content -Raw $installerPath | Invoke-Expression + if ($ErrorActionPreference -ne 'Continue' -or $env:PATH -ne $beforePath -or + (Get-Command Remove-DirectoryWithRetry -ErrorAction SilentlyContinue) -or + (Get-Variable Repo -Scope Local -ErrorAction SilentlyContinue)) { + throw 'FAIL: Piped installation leaked state into the caller.' + } + $ErrorActionPreference = 'Stop' + if (-not $fixture.Downloaded -or (Test-Path -LiteralPath $fixture.DownloadRoot)) { + throw 'FAIL: Public installation did not download and clean up the pipeline archive.' + } + } + finally { + $env:AZCLI_PACKAGE = $originalPackage + } + } + Write-Host 'PASS: Public download selection, failure handling, and piped installation using the pipeline archive.' +} + function Get-ArtifactConfigValue { param( [Parameter(Mandatory = $true)] @@ -388,7 +459,8 @@ $wrongRidName = $archiveName -replace "-$([regex]::Escape($currentAssetRid))\.zi $classicAz = Join-Path $classicBinRoot 'az.cmd' Set-Content -Path $classicAz -Value '@echo classic az sentinel' - & $installerPath -Package $packageProfile -ArchivePath $CliZipPath + & (Join-Path $packagingRoot 'common/test_public_extensions_download.ps1') -ExtensionsDirectory $ExtPackDir + Assert-PublicInstallerDownload if ($LASTEXITCODE -ne 0) { throw "FAIL: installer exited with code $LASTEXITCODE." } @@ -738,7 +810,7 @@ Write-Host "PASS: Resources package found: $($resNupkg.FullName)" Write-Host '' Write-Host '[17/17] Uninstall isolated azcli and preserve classic az' - & $uninstallerPath + Get-Content -Raw $uninstallerPath | Invoke-Expression if (Test-Path $installRoot) { throw 'FAIL: azcli install root remains after uninstall.' } diff --git a/docs/azure-cli/docs-conceptual/install-cli-extensions.md b/docs/azure-cli/docs-conceptual/install-cli-extensions.md index 01bd9a05d7..e872f1098f 100644 --- a/docs/azure-cli/docs-conceptual/install-cli-extensions.md +++ b/docs/azure-cli/docs-conceptual/install-cli-extensions.md @@ -27,19 +27,22 @@ Add-on extensions ship as NuGet packages named `azcli.extensions...tar.gz` containing every extension package plus `NOTICE`/`LICENSE`. This bundle is how add-on extensions are distributed. -Download the bundle from the latest release using your authenticated GitHub CLI (`gh`) — the same session the installer uses. (Or download `azcli-extensions-.tar.gz` manually from the repo's [GitHub Releases](https://github.com/Azure/azclips/releases) page, the same release you installed azcli from.) +Download `azcli-extensions-.tar.gz` from the public [GitHub Releases](https://github.com/Azure/azclips/releases) page, choosing the same release you installed azcli from. No GitHub account or token is required. -```bash -gh release download --repo Azure/azclips --pattern 'azcli-extensions-*.tar.gz' --dir . --clobber -``` +The download-only helpers below fetch the latest bundle and save it as `azcli-extensions.tar.gz` in the current directory. They do not extract or install extensions. If your CLI is from an older release, use the release-pinned examples below or download its bundle manually. The bundle unpacks as loose `.nupkg` files, so extract it into a folder and install from there. Extension package versions are independent of the release version, so the commands below don't hardcode a version — follow the set for your shell. -### Linux / macOS (bash) +### Linux / macOS / WSL (bash) + +Requires Bash, `curl`, `tar`, and standard system text utilities: ```bash +( +set -euo pipefail +curl -fsSL https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-extensions-download.sh | bash mkdir -p azcli-extensions -tar -xzf ./azcli-extensions-*.tar.gz -C azcli-extensions +tar -xzf ./azcli-extensions.tar.gz -C azcli-extensions # Install one extension (e.g. compute) az extension add --path ./azcli-extensions/azcli.extensions.compute.*.nupkg @@ -50,6 +53,13 @@ for pkg in ./azcli-extensions/azcli.extensions.*.nupkg; do done az extension list +) +``` + +For a specific release, replace the download command with: + +```bash +curl -fsSL https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-extensions-download.sh | bash -s -- --release '' ``` ### Windows (PowerShell) @@ -57,9 +67,10 @@ az extension list PowerShell doesn't expand `*` for external commands, so resolve the paths explicitly with `Get-ChildItem`: ```powershell +irm https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-extensions-download.ps1 -ErrorAction Stop | iex New-Item -ItemType Directory -Force -Path azcli-extensions | Out-Null -$bundle = Get-ChildItem azcli-extensions-*.tar.gz | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -tar -xzf $bundle.FullName -C azcli-extensions +tar -xzf ./azcli-extensions.tar.gz -C azcli-extensions +if ($LASTEXITCODE -ne 0) { throw 'Extensions bundle extraction failed.' } # Install one extension (e.g. compute) $compute = Get-ChildItem azcli-extensions/azcli.extensions.compute.*.nupkg | Select-Object -First 1 @@ -71,6 +82,13 @@ Get-ChildItem azcli-extensions/azcli.extensions.*.nupkg | ForEach-Object { az ex az extension list ``` +For a specific release, download the helper and pass the tag explicitly: + +```powershell +Invoke-WebRequest https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-extensions-download.ps1 -OutFile ./azcli-extensions-download.ps1 -ErrorAction Stop +./azcli-extensions-download.ps1 -Release '' +``` + ## Verify ```bash diff --git a/docs/azure-cli/docs-conceptual/install-cli-manual.md b/docs/azure-cli/docs-conceptual/install-cli-manual.md index 6656e15782..827bf4c564 100644 --- a/docs/azure-cli/docs-conceptual/install-cli-manual.md +++ b/docs/azure-cli/docs-conceptual/install-cli-manual.md @@ -5,12 +5,14 @@ description: Learn how to install the Azure CLI public preview manually or offli # Install the Azure CLI public preview manually or offline -> **Most users should use the [one-command installer](../README.md).** Use this guide for offline installs, as a fallback when `gh` isn't available, when you already have the archive, or to understand the steps the installer performs. +> **Most users should use the [one-command installer](./install-overview.md).** Use this guide for offline installs, when you already have the archive, or to understand the steps the installer performs. Get from a downloaded azcli archive to a working `az` command: pick the file for your OS and CPU, extract it, and run `az` from that folder. Adding it to `PATH` is optional and manual. ## Pick your download +Download the archive from the public [GitHub Releases](https://github.com/Azure/azclips/releases) page. No GitHub account or token is required. + First choose a package type: | Package | File name starts with | Use when | diff --git a/docs/azure-cli/docs-conceptual/install-overview.md b/docs/azure-cli/docs-conceptual/install-overview.md index 7504d093f2..0f978664ec 100644 --- a/docs/azure-cli/docs-conceptual/install-overview.md +++ b/docs/azure-cli/docs-conceptual/install-overview.md @@ -15,36 +15,53 @@ The Azure CLI public preview ships as **azcli** — the `az` command-line tool ( ## Install azcli (one command) -Public preview artifacts are available from the [GitHub Releases](https://github.com/Azure/azclips/releases) page. The installer uses your authenticated GitHub CLI (`gh`) session — no personal access tokens or manual asset URLs. +Public preview artifacts are available from the [GitHub Releases](https://github.com/Azure/azclips/releases) page. The installer downloads the latest release anonymously: no GitHub CLI, account, or token is required. -**Prerequisites** — install the [GitHub CLI](https://cli.github.com/) and sign in once: +- **Linux / macOS / WSL:** Bash, `curl`, `tar`, and standard system text utilities. No GitHub CLI, JSON parser, or Python installation is required. +- **Windows:** PowerShell 7+. + +**Linux / macOS / WSL:** ```bash -gh auth login # verify with: gh auth status +curl -fsSL https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-install.sh | bash ``` -**Linux / macOS:** +**Windows (PowerShell 7+):** -```bash -gh api repos/Azure/azclips/contents/install/azcli-install.sh -H 'Accept: application/vnd.github.raw' | bash +```powershell +irm https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-install.ps1 -ErrorAction Stop | iex ``` -**Windows (PowerShell 7+):** +For the lean package: + +```bash +curl -fsSL https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-install.sh | AZCLI_PACKAGE=lean bash +``` ```powershell -gh api repos/Azure/azclips/contents/install/azcli-install.ps1 -H 'Accept: application/vnd.github.raw' > azcli-install.ps1 && ./azcli-install.ps1 +$env:AZCLI_PACKAGE='lean'; irm https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-install.ps1 -ErrorAction Stop | iex ``` -Pass installer options after `--` (bash) or as parameters (PowerShell) — for example the lean package: +The Bash assignment applies only to the installer process. The PowerShell variable persists for the current session; run `Remove-Item Env:AZCLI_PACKAGE` afterward to restore the default full-package selection. + +### Prefer to review the script first? + +The commands above execute downloaded code. To inspect it before execution, download the script and run the execution command only after a successful download and review: ```bash -gh api repos/Azure/azclips/contents/install/azcli-install.sh -H 'Accept: application/vnd.github.raw' | bash -s -- --package lean +curl -fsSL https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-install.sh -o azcli-install.sh +# Review the file, then: +bash ./azcli-install.sh ``` ```powershell -gh api repos/Azure/azclips/contents/install/azcli-install.ps1 -H 'Accept: application/vnd.github.raw' > azcli-install.ps1 && ./azcli-install.ps1 -Package lean +Invoke-WebRequest https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-install.ps1 -OutFile ./azcli-install.ps1 -ErrorAction Stop +# Review the file, then: +./azcli-install.ps1 ``` +Pass options to a saved script with `bash ./azcli-install.sh --package lean` or `./azcli-install.ps1 -Package lean`. + ### Installer options | Option (bash) | Option (PowerShell) | Default | Purpose | @@ -81,22 +98,28 @@ By default the installer does **not** modify your `PATH`, so azcli (`az`) instal ## Uninstall ```bash -# Linux / macOS -gh api repos/Azure/azclips/contents/install/azcli-uninstall.sh -H 'Accept: application/vnd.github.raw' | bash +# Linux / macOS / WSL +curl -fsSL https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-uninstall.sh | bash ``` ```powershell # Windows -gh api repos/Azure/azclips/contents/install/azcli-uninstall.ps1 -H 'Accept: application/vnd.github.raw' > azcli-uninstall.ps1 && ./azcli-uninstall.ps1 +irm https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-uninstall.ps1 -ErrorAction Stop | iex ``` -Removes the install directory. Pass `--force` / `-Force` to remove a folder that doesn't look like an azcli install. +Removes the install directory. Download and review the uninstall script first if preferred. To remove a folder that doesn't look like an azcli install, pass `--force` with `bash -s -- --force`, or download the PowerShell script and run `./azcli-uninstall.ps1 -Force`. + +## Download troubleshooting + +The installer reports failed downloads and missing or ambiguous release assets. GitHub's anonymous API is rate-limited; if a release lookup returns HTTP 403 or 429, check GitHub's response and retry after the rate limit resets. You can also download the archive from [GitHub Releases](https://github.com/Azure/azclips/releases) and follow the [manual guide](./install-cli-manual.md). + +Piped Bash execution may not report the downloader's exit status. If the bootstrap download fails, do not assume installation succeeded; use the download-and-review workflow to check download success separately. ## Other install scenarios | You want to… | Guide | | --- | --- | -| Install azcli manually / offline (no `gh`, or you already have the archive) | [install-cli-manual.md](./install-cli-manual.md) | +| Install azcli manually / offline (you already have the archive, or prefer not to use the installer) | [install-cli-manual.md](./install-cli-manual.md) | | Add an add-on azcli extension (lean installs, or a specific version) | [install-cli-extensions.md](./install-cli-extensions.md) | | Enable `az` tab completion (bash / zsh / PowerShell) | [install-cli-completions.md](./install-cli-completions.md) | | Enable AI-based error recovery in Azure CLI | [ai-based-error-recovery.md](./ai-based-error-recovery.md) | diff --git a/docs/azure-powershell/docs-conceptual/install-overview.md b/docs/azure-powershell/docs-conceptual/install-overview.md index 1e42f8d69c..fea2b35d4b 100644 --- a/docs/azure-powershell/docs-conceptual/install-overview.md +++ b/docs/azure-powershell/docs-conceptual/install-overview.md @@ -12,23 +12,31 @@ The Azure PowerShell public preview ships as **azps** — the Az PowerShell modu ## Install azps (one command) -Public preview artifacts are available from the [GitHub Releases](https://github.com/Azure/azclips/releases) page. The installer uses your authenticated GitHub CLI (`gh`) session. +Public preview artifacts are available from the [GitHub Releases](https://github.com/Azure/azclips/releases) page. The installer downloads the latest release anonymously: no GitHub CLI, account, or token is required. **Prerequisites:** - [PowerShell 7.6+](https://aka.ms/powershell) -- [GitHub CLI](https://cli.github.com/) signed in (`gh auth login`) +- `tar` available on `PATH` to extract the bundle -**Download and install:** +**Linux / macOS / WSL / Windows** (run inside PowerShell 7.6+, `pwsh`): ```powershell -gh release download --repo Azure/azclips --pattern 'azps-cmdlets-*.tar.gz' --dir . --clobber -New-Item -ItemType Directory -Force -Path azps-cmdlets | Out-Null -$bundle = Get-ChildItem azps-cmdlets-*.tar.gz | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -tar -xzf $bundle.FullName -C azps-cmdlets -./azps-cmdlets/InstallModule.ps1 +irm https://raw.githubusercontent.com/Azure/azclips/main/install/azps-install.ps1 -ErrorAction Stop | iex ``` +The bootstrap downloads and extracts the bundle into a temporary directory, invokes its `InstallModule.ps1` helper for the current user, and removes its temporary repository and files afterward. + +To review the script first, download it and execute only after successful download and review: + +```powershell +Invoke-WebRequest https://raw.githubusercontent.com/Azure/azclips/main/install/azps-install.ps1 -OutFile ./azps-install.ps1 -ErrorAction Stop +# Review the file, then: +./azps-install.ps1 +``` + +For module/version/scope options or manual/offline installation, see the [module installation guide](./install-powershell-modules.md). + ## Verify ```powershell diff --git a/docs/azure-powershell/docs-conceptual/install-powershell-modules.md b/docs/azure-powershell/docs-conceptual/install-powershell-modules.md index 4714f801b6..16c8b149b7 100644 --- a/docs/azure-powershell/docs-conceptual/install-powershell-modules.md +++ b/docs/azure-powershell/docs-conceptual/install-powershell-modules.md @@ -22,45 +22,55 @@ The Az PowerShell cmdlets ship as NuGet module packages (`Az.*.nupkg`). Each rel Check your version with `$PSVersionTable.PSVersion`. Install or update PowerShell from if needed. -## Install with the bundled script +## Install with the bootstrap All steps below run in **PowerShell 7.6+**. -Download the bundle from the latest release using your authenticated GitHub CLI (`gh`). (Or download `azps-cmdlets-.tar.gz` manually from the repo's [GitHub Releases](https://github.com/Azure/azclips/releases) page.) +The bootstrap downloads the latest public release and invokes its bundled module installer. No GitHub CLI, GitHub sign-in, or token is required. `tar` must be available on `PATH`. ```powershell -gh release download --repo Azure/azclips --pattern 'azps-cmdlets-*.tar.gz' --dir . --clobber +irm https://raw.githubusercontent.com/Azure/azclips/main/install/azps-install.ps1 -ErrorAction Stop | iex ``` -Extract it: +To inspect the bootstrap or pass options, save it first: ```powershell -New-Item -ItemType Directory -Force -Path azps-cmdlets | Out-Null -$bundle = Get-ChildItem azps-cmdlets-*.tar.gz | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -tar -xzf $bundle.FullName -C azps-cmdlets +Invoke-WebRequest https://raw.githubusercontent.com/Azure/azclips/main/install/azps-install.ps1 -OutFile ./azps-install.ps1 -ErrorAction Stop +# Review the file, then: +./azps-install.ps1 ``` -Install it (registers the extracted folder as a temporary local repository and installs the `Az` roll-up and all `Az.*` dependencies for the current user): +The bootstrap installs the `Az` roll-up and its dependencies for the current user by default. It uses a unique temporary local PSRepository and removes that repository and the extracted files afterward. -```powershell -./azps-cmdlets/InstallModule.ps1 -``` - -### Script options +### Bootstrap options | Parameter | Default | Purpose | | --- | --- | --- | | `-ModuleName` | `Az` | Install a single module instead of the roll-up, e.g. `-ModuleName Az.Compute`. | | `-RequiredVersion` | highest packaged version of the module selected by `-ModuleName` | Pin a specific version. | | `-Scope` | `CurrentUser` | Use `AllUsers` for a machine-wide install (needs elevation). | -| `-RepositoryName` | `LocalAzclips` | Name of the temporary local PSRepository. | +| `-RepositoryName` | unique `AzclipsInstall-...` name | Name of the temporary local PSRepository; must not already exist. | Example — install just the Compute module for all users: ```powershell -./azps-cmdlets/InstallModule.ps1 -ModuleName Az.Compute -Scope AllUsers +./azps-install.ps1 -ModuleName Az.Compute -Scope AllUsers ``` +## Manual / offline installation + +Download `azps-cmdlets-.tar.gz` from the public [GitHub Releases](https://github.com/Azure/azclips/releases) page. Use a fresh extraction directory for each release: + +```powershell +New-Item -ItemType Directory -Path azps-cmdlets | Out-Null +$bundle = Get-Item ./azps-cmdlets-.tar.gz +tar -xzf $bundle.FullName -C azps-cmdlets +if ($LASTEXITCODE -ne 0) { throw 'PowerShell bundle extraction failed.' } +./azps-cmdlets/InstallModule.ps1 +``` + +Replace `` with the downloaded filename's version. The bundled helper accepts the same module/version/scope parameters, but its repository-name default is `LocalAzclips`. After installation, run `Unregister-PSRepository -Name LocalAzclips` before removing the extracted directory. + ## Verify ```powershell @@ -79,3 +89,5 @@ The `Az.*` modules should be listed. Cmdlets that contact Azure require sign-in | `Install-Module` cannot find the module | Run it from the extracted `azps-cmdlets` folder, or pass the correct `-Repository`/`SourceLocation` path. | | Command conflicts with an existing Az install | Open a fresh PowerShell session, or use `-AllowClobber`. | | Need a machine-wide install | Use `-Scope AllUsers` from an elevated PowerShell session. | +| Release lookup returns HTTP 403 or 429 | Check GitHub's response for an anonymous API rate limit; retry after it resets, or download the bundle manually from GitHub Releases. | +| Bootstrap says the repository already exists | Choose a different `-RepositoryName`; the bootstrap does not replace existing repositories. | diff --git a/docs/pp-scenarios/scenario-1-get-started-and-profiles.md b/docs/pp-scenarios/scenario-1-get-started-and-profiles.md index b1e8061336..c39219edd6 100644 --- a/docs/pp-scenarios/scenario-1-get-started-and-profiles.md +++ b/docs/pp-scenarios/scenario-1-get-started-and-profiles.md @@ -6,7 +6,7 @@ ## Prerequisites -- [GitHub CLI](https://cli.github.com/), signed in with `gh auth login` and with access to the [AzCLIPS releases](https://github.com/Azure/azclips/releases). +- Bash, `curl`, `tar`, and standard system text utilities for the CLI Bash path; `tar` for the PowerShell module path. Downloads from the public [AzCLIPS releases](https://github.com/Azure/azclips/releases) require no GitHub account or token. - [PowerShell 7.6+](https://aka.ms/powershell) for the AzCLIPS PowerShell modules and for the Windows CLI path. - Your existing (classic) Azure CLI and/or Azure PowerShell, already installed. Step 4 switches between them and the preview. - Two Azure subscriptions or accounts you can sign in to. They can belong to the same tenant. @@ -15,7 +15,7 @@ Find each subscription ID and its directory (tenant) ID in **Azure portal > Subs ## User journey -Choose one command path below: **AzCLIPS CLI in Bash** (Linux and macOS), **AzCLIPS CLI in PowerShell** (Windows) or **AzCLIPS PowerShell**. Each path follows the same steps. Installing the preview does not replace your existing Azure CLI or Azure PowerShell. The preview does not share sign-in context or tokens with them, so profiles switch work environments within the preview only. +Choose one command path below: **AzCLIPS CLI in Bash** (Linux, macOS, and WSL), **AzCLIPS CLI in PowerShell** (Windows) or **AzCLIPS PowerShell**. Each path follows the same steps. Installing the preview does not replace your existing Azure CLI or Azure PowerShell. The preview does not share sign-in context or tokens with them, so profiles switch work environments within the preview only. - **1. Install the preview:** Install the preview next to your existing tools and make it active for the current session. - **2. Verify the installation:** Confirm the preview starts and is the one your session uses. @@ -45,8 +45,7 @@ profile_b="review-b-$run" **1. Install the preview** ```bash -gh api repos/Azure/azclips/contents/install/azcli-install.sh -H 'Accept: application/vnd.github.raw' > azcli-install.sh -bash azcli-install.sh +curl -fsSL https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-install.sh | bash export PATH="$HOME/.local/share/azcli:$PATH" ``` @@ -70,13 +69,15 @@ az extension show --name compute If `compute` is not listed, download the extensions bundle from the release you installed, unpack it and add the extension: ```bash -gh release download --repo Azure/azclips --pattern 'azcli-extensions-*.tar.gz' --dir . --clobber +curl -fsSL https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-extensions-download.sh | bash mkdir -p azcli-extensions -tar -xzf ./azcli-extensions-*.tar.gz -C azcli-extensions +tar -xzf ./azcli-extensions.tar.gz -C azcli-extensions az extension add --path ./azcli-extensions/azcli.extensions.compute.*.nupkg az extension list ``` +The helper defaults to the latest release. For an older CLI, use `bash -s -- --release ''` instead of `bash` in the download command, or download the matching bundle manually. + **4. Switch between classic and preview** Define a helper that puts the preview on and off your `PATH` for this shell only. Switch to the classic Azure CLI and back. @@ -155,14 +156,14 @@ $profileB = "review-b-$run" ```powershell if ($IsWindows) { # Windows - gh api repos/Azure/azclips/contents/install/azcli-install.ps1 -H 'Accept: application/vnd.github.raw' > azcli-install.ps1 - .\azcli-install.ps1 + irm https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-install.ps1 -ErrorAction Stop | iex $env:PATH = "$env:LOCALAPPDATA\Programs\azcli$([IO.Path]::PathSeparator)$env:PATH" } else { # macOS and Linux - gh api repos/Azure/azclips/contents/install/azcli-install.sh -H 'Accept: application/vnd.github.raw' > azcli-install.sh - bash azcli-install.sh + Invoke-WebRequest https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-install.sh -OutFile ./azcli-install.sh -ErrorAction Stop + bash ./azcli-install.sh + if ($LASTEXITCODE -ne 0) { throw 'CLI installation failed.' } $env:PATH = "$HOME/.local/share/azcli$([IO.Path]::PathSeparator)$env:PATH" } ``` @@ -187,10 +188,10 @@ az extension show --name compute If `compute` is not listed, download the extensions bundle from the release you installed, unpack it and add the extension: ```powershell -gh release download --repo Azure/azclips --pattern 'azcli-extensions-*.tar.gz' --dir . --clobber +irm https://raw.githubusercontent.com/Azure/azclips/main/install/azcli-extensions-download.ps1 -ErrorAction Stop | iex New-Item -ItemType Directory -Force -Path azcli-extensions | Out-Null -$bundle = Get-ChildItem azcli-extensions-*.tar.gz | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -tar -xzf $bundle.FullName -C azcli-extensions +tar -xzf ./azcli-extensions.tar.gz -C azcli-extensions +if ($LASTEXITCODE -ne 0) { throw 'Extensions bundle extraction failed.' } $compute = Get-ChildItem azcli-extensions/azcli.extensions.compute.*.nupkg | Select-Object -First 1 az extension add --path $compute.FullName az extension list @@ -296,15 +297,20 @@ $profileB = "review-b-$run" **1. Install the preview** -Download the module bundle into a new temporary folder. The bundled installer installs the modules without replacing your existing ones. +Download the module bundle into a new temporary folder. This scenario uses the manual bundle workflow so it can read the exact component versions for the import commands below. For the simpler default install, use the [one-command bootstrap](../azure-powershell/docs-conceptual/install-overview.md). ```powershell $stage = Join-Path ([IO.Path]::GetTempPath()) "azclips-modules-$run" New-Item -ItemType Directory -Path $stage | Out-Null -gh release download --repo Azure/azclips --pattern 'azps-cmdlets-*.tar.gz' --dir $stage -$bundle = Get-ChildItem -LiteralPath $stage -Filter 'azps-cmdlets-*.tar.gz' -tar -xzf $bundle.FullName -C $stage +$release = Invoke-RestMethod https://api.github.com/repos/Azure/azclips/releases/latest -ErrorAction Stop +$assets = @($release.assets | Where-Object name -Like 'azps-cmdlets-*.tar.gz') +if ($assets.Count -ne 1) { throw 'Expected exactly one PowerShell bundle.' } +$archive = Join-Path $stage $assets[0].name +Invoke-WebRequest $assets[0].browser_download_url -OutFile $archive -ErrorAction Stop +tar -xzf $archive -C $stage +if ($LASTEXITCODE -ne 0) { throw 'PowerShell bundle extraction failed.' } & (Join-Path $stage 'InstallModule.ps1') +Unregister-PSRepository -Name LocalAzclips $accountsPackage = Get-ChildItem -LiteralPath $stage -Filter 'Az.Accounts.*.nupkg' $accountsVersion = $accountsPackage.BaseName.Substring('Az.Accounts.'.Length) $resourcesPackage = Get-ChildItem -LiteralPath $stage -Filter 'Az.Resources.*.nupkg' diff --git a/install/azcli-extensions-download.ps1 b/install/azcli-extensions-download.ps1 new file mode 100644 index 0000000000..5cbd69d3bc --- /dev/null +++ b/install/azcli-extensions-download.ps1 @@ -0,0 +1,50 @@ +<# +.SYNOPSIS + Downloads the public CLI extensions bundle into the current directory. +.DESCRIPTION + Saves azcli-extensions.tar.gz without extracting or installing extensions. + No GitHub CLI, authentication, or external JSON parser is required. +.EXAMPLE + ./azcli-extensions-download.ps1 + ./azcli-extensions-download.ps1 -Release v0.1.7.20261104T200741Z +#> +& { + [CmdletBinding()] + param( + [ValidatePattern('^[A-Za-z0-9._-]+$')] + [string]$Release + ) + + $ErrorActionPreference = 'Stop' + if (Test-Path -LiteralPath './azcli-extensions.tar.gz') { + $destination = Get-Item -LiteralPath './azcli-extensions.tar.gz' -Force + if ($destination.PSIsContainer -or ($destination.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'Refusing to replace a non-file destination: ./azcli-extensions.tar.gz.' + } + } + $endpoint = 'https://api.github.com/repos/Azure/azclips/releases/latest' + if ($Release) { $endpoint = "https://api.github.com/repos/Azure/azclips/releases/tags/$Release" } + $stage = Join-Path (Get-Location).ProviderPath ".azcli-extensions-download-$([guid]::NewGuid().ToString('N'))" + try { + $metadata = Invoke-RestMethod $endpoint -ErrorAction Stop + if ($metadata.tag_name -cnotmatch '^[A-Za-z0-9._-]+$' -or + ($Release -and $metadata.tag_name -cne $Release)) { + throw 'Release metadata has no valid matching tag_name.' + } + $assets = @($metadata.assets | Where-Object { $_.name -cmatch '^azcli-extensions-[0-9][A-Za-z0-9._-]*[.]tar[.]gz$' }) + if ($assets.Count -ne 1) { throw "Expected exactly one extensions bundle; found $($assets.Count)." } + $asset = $assets[0] + $url = "https://github.com/Azure/azclips/releases/download/$($metadata.tag_name)/$($asset.name)" + if ($asset.browser_download_url -cne $url) { throw 'Extensions bundle has an invalid GitHub download URL.' } + New-Item -ItemType Directory -Path $stage | Out-Null + $archive = Join-Path $stage 'azcli-extensions.tar.gz' + Invoke-WebRequest $url -OutFile $archive -ErrorAction Stop + if (-not (Test-Path -LiteralPath $archive -PathType Leaf)) { throw 'Download did not produce the expected archive.' } + Move-Item -LiteralPath $archive -Destination './azcli-extensions.tar.gz' -Force + Write-Host "Downloaded: $url" + Write-Host 'Saved to: ./azcli-extensions.tar.gz (not extracted or installed)' + } + finally { + if (Test-Path -LiteralPath $stage) { Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction Stop } + } +} @args diff --git a/install/azcli-extensions-download.sh b/install/azcli-extensions-download.sh new file mode 100644 index 0000000000..c73f6649d8 --- /dev/null +++ b/install/azcli-extensions-download.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# +# Download only: extraction and az extension add remain explicit user steps. +main() { + set -euo pipefail + release_tag="" + stage="" + die() { echo "ERROR: $*" >&2; exit 1; } + cleanup() { [[ -z "$stage" ]] || rm -rf "$stage"; } + trap cleanup EXIT + + while [[ $# -gt 0 ]]; do + case "$1" in + --release) + [[ $# -ge 2 && -n "$2" ]] || die "--release requires a release tag." + release_tag="$2"; shift 2 ;; + -h|--help) + echo "Usage: azcli-extensions-download.sh [--release ]" + echo "Downloads azcli-extensions.tar.gz into the current directory; does not extract or install." + return ;; + *) die "Unknown argument: $1 (use --help)." ;; + esac + done + [[ -z "$release_tag" || "$release_tag" =~ ^[A-Za-z0-9._-]+$ ]] || + die "Invalid release tag. Use letters, digits, dots, underscores, and hyphens." + [[ ! -L ./azcli-extensions.tar.gz && (! -e ./azcli-extensions.tar.gz || -f ./azcli-extensions.tar.gz) ]] || + die "Refusing to replace a non-file destination: ./azcli-extensions.tar.gz." + command -v curl >/dev/null 2>&1 || die "curl is required for public downloads." + endpoint="https://api.github.com/repos/Azure/azclips/releases/latest" + [[ -z "$release_tag" ]] || endpoint="https://api.github.com/repos/Azure/azclips/releases/tags/$release_tag" + stage="$(mktemp -d "./.azcli-extensions-download.XXXXXX")" + curl --fail --show-error --silent --location "$endpoint" -o "$stage/release.json" || + die "Could not retrieve the release. Check network access and GitHub's anonymous API rate limit." + tr -d '\r\n' < "$stage/release.json" > "$stage/release-line.txt" + # Include the closing quote so a URL with a query or extra path cannot match a prefix. + grep -oE '"browser_download_url"[[:space:]]*:[[:space:]]*"https://github[.]com/Azure/azclips/releases/download/[A-Za-z0-9._-]+/azcli-extensions-[0-9][A-Za-z0-9._-]*[.]tar[.]gz"' \ + "$stage/release-line.txt" > "$stage/download-fields.txt" || { + [[ $? -eq 1 ]] || die "Could not read release download links." + } + matches=0 + url="" + while IFS= read -r field; do + candidate_url="https://${field#*\"https://}" + candidate_url="${candidate_url%\"}" + candidate_release="${candidate_url%/*}" + candidate_release="${candidate_release##*/}" + [[ -z "$release_tag" || "$candidate_release" == "$release_tag" ]] || continue + matches=$((matches + 1)) + url="$candidate_url" + done < "$stage/download-fields.txt" + [[ "$matches" -eq 1 ]] || die "Expected exactly one extensions bundle download URL; found $matches." + curl --fail --show-error --location "$url" -o "$stage/azcli-extensions.tar.gz" || + die "Could not download the extensions bundle. Check network access and retry." + [[ -f "$stage/azcli-extensions.tar.gz" ]] || die "Download did not produce the expected archive." + mv -f "$stage/azcli-extensions.tar.gz" ./azcli-extensions.tar.gz + echo "Downloaded: $url" + echo "Saved to: ./azcli-extensions.tar.gz (not extracted or installed)" +} + +main "$@" diff --git a/install/azcli-install.ps1 b/install/azcli-install.ps1 index ef31817506..3ab7f1336c 100644 --- a/install/azcli-install.ps1 +++ b/install/azcli-install.ps1 @@ -3,18 +3,17 @@ azcli installer (Windows). .DESCRIPTION - Downloads the latest signed release of the az CLI from a PRIVATE GitHub repo - using your authenticated GitHub CLI (gh) session. + Downloads the latest signed release of the az CLI from public GitHub Releases. + No GitHub account or token is required. The CLI is installed in isolation and PATH is NOT modified, so it coexists with the classic azure-cli. - Private-preview users only need read access to the repo and a logged-in `gh`. - .EXAMPLE ./azcli-install.ps1 ./azcli-install.ps1 -Package lean #> +& { [CmdletBinding()] param( [ValidateSet('lean', 'full')] @@ -133,50 +132,36 @@ try { Write-Host "Archive : $archive" } else { - # 2. Require an authenticated GitHub CLI. - if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { - throw "GitHub CLI 'gh' is required. Install it from https://cli.github.com/ and re-run." - } - gh auth status *> $null - if ($LASTEXITCODE -ne 0) { - Write-Host "You are not signed in to GitHub. Launching 'gh auth login'..." - gh auth login - if ($LASTEXITCODE -ne 0) { throw "gh auth login did not complete." } - } - - # 3. Resolve the latest release tag. - $tag = (gh release view --repo $Repo --json tagName --jq '.tagName') - if ($LASTEXITCODE -ne 0 -or -not $tag) { - throw "Could not resolve a release for $Repo. Confirm you have read access and that a release exists." + # Resolve names from metadata: release tags and package versions can differ. + $release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest" -ErrorAction Stop + $tag = $release.tag_name + if ([string]::IsNullOrWhiteSpace($tag)) { + throw "Release metadata for $Repo has no valid tag_name." } Write-Host "Release : $tag" # 4. Choose the matching asset for this platform + package. - $assetNames = gh release view $tag --repo $Repo --json assets --jq '.assets[].name' - if ($LASTEXITCODE -ne 0 -or -not $assetNames) { - throw "Release $tag has no downloadable assets." - } - - $chosen = $assetNames | Where-Object { - $_.EndsWith($suffix) -and ( - ($Package -eq 'lean' -and $_ -like "$leanStem-*") -or - ($Package -eq 'full' -and $_ -like "$fullStem-*" -and $_ -notlike "$leanStem-*") + $assets = @($release.assets | Where-Object { + $_.name -is [string] -and $_.name.EndsWith($suffix) -and ( + ($Package -eq 'lean' -and $_.name -like "$leanStem-*") -or + ($Package -eq 'full' -and $_.name -like "$fullStem-*" -and $_.name -notlike "$leanStem-*") ) - } | Select-Object -First 1 + }) - if (-not $chosen) { - Write-Host "Available assets in ${tag}:" - $assetNames | ForEach-Object { Write-Host " $_" } - throw "No $Package CLI asset found for $assetLabel in release $tag." + if ($assets.Count -ne 1) { + throw "Expected exactly one $Package CLI asset for $assetLabel in release $tag; found $($assets.Count)." + } + $asset = $assets[0] + $chosen = $asset.name + if ($chosen -notmatch '^[A-Za-z0-9._-]+$' -or $asset.browser_download_url -notmatch '^https://') { + throw 'Release asset has an invalid filename or HTTPS download URL.' } Write-Host "Asset : $chosen" - # 5. Download via the authenticated gh session (handles private-repo access). $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString()) New-Item -ItemType Directory -Path $tmp -Force | Out-Null - gh release download $tag --repo $Repo --pattern $chosen --dir $tmp --clobber - if ($LASTEXITCODE -ne 0) { throw "gh release download failed for $chosen." } $archive = Join-Path $tmp $chosen + Invoke-WebRequest $asset.browser_download_url -OutFile $archive -ErrorAction Stop if (-not (Test-Path $archive)) { throw "Download did not produce the expected file: $archive" } } @@ -222,3 +207,4 @@ Write-Host "Done. Installed in isolation - PATH was not modified (coexists with Write-Host "" Write-Host " Run it directly: $azExe --help" Write-Host " Or add to PATH (this session): `$env:Path = `"$InstallRoot;`$env:Path`"" +} @args diff --git a/install/azcli-install.sh b/install/azcli-install.sh index 085be426a7..8d1e7dc7ee 100755 --- a/install/azcli-install.sh +++ b/install/azcli-install.sh @@ -6,13 +6,14 @@ # then extracts and installs the `az` command in isolation (PATH is NOT # modified, so it coexists with the classic azure-cli). # -# Private-preview users only need read access to the repo and a logged-in `gh`. +# Public downloads do not require a GitHub account or token. # # Usage: # ./azcli-install.sh [--package lean|full] # # Environment overrides: AZCLI_PACKAGE # +main() { set -euo pipefail # REPO and INSTALL_ROOT are fixed. Their AZCLI_REPO / AZCLI_INSTALL_ROOT env @@ -35,7 +36,12 @@ cleanup() { trap cleanup EXIT usage() { - sed -n '3,15p' "$0" | sed 's/^# \{0,1\}//' + cat <<'HELP' +azcli installer (Linux / macOS / WSL) +Usage: azcli-install.sh [--package lean|full] +Environment override: AZCLI_PACKAGE (default: full) +Online installation requires curl, tar, and standard text utilities. +HELP exit 0 } @@ -122,47 +128,42 @@ if [[ -n "$ARCHIVE_PATH" ]]; then fi echo "Archive : $archive" else - # 2. Require an authenticated GitHub CLI. - command -v gh >/dev/null 2>&1 || die "GitHub CLI 'gh' is required. Install it from https://cli.github.com/ and re-run." - if ! gh auth status >/dev/null 2>&1; then - echo "You are not signed in to GitHub. Launching 'gh auth login'..." - gh auth login - fi + command -v curl >/dev/null 2>&1 || die "curl is required for public downloads." - # 3. Resolve the latest release tag. - tag="$(gh release view --repo "$REPO" --json tagName --jq '.tagName' 2>/dev/null || true)" - [[ -n "$tag" ]] || die "Could not resolve a release for $REPO. Confirm you have read access and that a release exists." + # Resolve names from metadata: release tags and package versions can differ. + tmp="$(mktemp -d)" + curl --fail --show-error --silent --location \ + "https://api.github.com/repos/$REPO/releases/latest" -o "$tmp/release.json" || + die "Could not retrieve the latest release for $REPO. Check network access and GitHub's anonymous API rate limit." + # Match complete, quoted GitHub asset URL fields, never URL prefixes or links in release notes. + tr -d '\r\n' < "$tmp/release.json" > "$tmp/release-line.txt" + grep -oE '"browser_download_url"[[:space:]]*:[[:space:]]*"https://github[.]com/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+/releases/download/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+"' \ + "$tmp/release-line.txt" > "$tmp/download-fields.txt" || { + [[ $? -eq 1 ]] || die "Could not read release download links." + } + prefix="https://github.com/$REPO/releases/download/" + matches=0 + url="" + while IFS= read -r field; do + candidate_url="https://${field#*\"https://}" + candidate_url="${candidate_url%\"}" + [[ "$candidate_url" == "$prefix"* ]] || continue + candidate_name="${candidate_url##*/}" + [[ "$candidate_name" == "$stem"-[0-9]*"$suffix" ]] || continue + matches=$((matches + 1)) + url="$candidate_url" + done < "$tmp/download-fields.txt" + [[ "$matches" -eq 1 ]] || + die "Expected exactly one $PACKAGE CLI download URL for $asset_label; found $matches." + chosen="${url##*/}" + release_path="${url%/*}" + tag="${release_path##*/}" echo "Release : $tag" - - # 4. Choose the matching asset for this platform + package. - # (Portable read loop; macOS ships bash 3.2 which lacks `mapfile`.) - asset_names=() - while IFS= read -r name; do - [[ -n "$name" ]] && asset_names+=("$name") - done < <(gh release view "$tag" --repo "$REPO" --json assets --jq '.assets[].name') - [[ ${#asset_names[@]} -gt 0 ]] || die "Release $tag has no downloadable assets." - - chosen="" - for name in "${asset_names[@]}"; do - [[ "$name" == *"$suffix" ]] || continue - if [[ "$PACKAGE" == "lean" ]]; then - [[ "$name" == azcli-lean-* ]] && { chosen="$name"; break; } - else - [[ "$name" == azcli-* && "$name" != azcli-lean-* ]] && { chosen="$name"; break; } - fi - done - - if [[ -z "$chosen" ]]; then - echo "Available assets in $tag:" >&2 - printf ' %s\n' "${asset_names[@]}" >&2 - die "No $PACKAGE CLI asset found for $asset_label in release $tag." - fi echo "Asset : $chosen" - # 5. Download via the authenticated gh session (handles private-repo access). - tmp="$(mktemp -d)" - gh release download "$tag" --repo "$REPO" --pattern "$chosen" --dir "$tmp" --clobber archive="$tmp/$chosen" + curl --fail --show-error --location "$url" -o "$archive" || + die "Could not download $chosen. Check network access and retry." [[ -f "$archive" ]] || die "Download did not produce the expected file: $archive" fi @@ -194,3 +195,6 @@ echo "Done. Installed in isolation — PATH was not modified (coexists with clas echo echo " Run it directly: $INSTALL_ROOT/az --help" echo " Or add to PATH (this session): export PATH=\"$INSTALL_ROOT:\$PATH\"" +} + +main "$@" diff --git a/install/azcli-uninstall.ps1 b/install/azcli-uninstall.ps1 index 053892fc9e..9777717a31 100644 --- a/install/azcli-uninstall.ps1 +++ b/install/azcli-uninstall.ps1 @@ -9,6 +9,7 @@ ./azcli-uninstall.ps1 ./azcli-uninstall.ps1 -Force #> +& { [CmdletBinding()] param( [switch]$Force @@ -68,3 +69,4 @@ else { Write-Host "" Write-Host "Done." +} @args diff --git a/install/azcli-uninstall.sh b/install/azcli-uninstall.sh index 19ac57ff7d..5c5f39cce9 100755 --- a/install/azcli-uninstall.sh +++ b/install/azcli-uninstall.sh @@ -7,6 +7,7 @@ # Usage: # ./azcli-uninstall.sh [--force] # +main() { set -euo pipefail # INSTALL_ROOT is the fixed default; the AZCLI_INSTALL_ROOT env override is internal. @@ -16,7 +17,11 @@ FORCE=0 die() { echo "ERROR: $*" >&2; exit 1; } usage() { - sed -n '3,8p' "$0" | sed 's/^# \{0,1\}//' + cat <<'HELP' +azcli uninstaller (Linux / macOS / WSL) +Usage: azcli-uninstall.sh [--force] +Removes the installation folder. --force allows removing an unrecognized folder. +HELP exit 0 } @@ -49,3 +54,6 @@ fi echo echo "Done." +} + +main "$@" diff --git a/install/azps-install.ps1 b/install/azps-install.ps1 new file mode 100644 index 0000000000..a68e0fbf97 --- /dev/null +++ b/install/azps-install.ps1 @@ -0,0 +1,76 @@ +<# +.SYNOPSIS + Installs the Azure PowerShell preview modules on Windows, Linux, and macOS. +.DESCRIPTION + Downloads the latest public release bundle without GitHub authentication and + delegates installation to its InstallModule.ps1 helper. Requires PowerShell 7.6+. +.EXAMPLE + ./azps-install.ps1 + ./azps-install.ps1 -ModuleName Az.Compute -Scope AllUsers +#> +& { +[CmdletBinding()] +param( + [string]$ModuleName = 'Az', + [string]$RequiredVersion, + [ValidateSet('CurrentUser', 'AllUsers')] + [string]$Scope = 'CurrentUser', + [string]$RepositoryName = "AzclipsInstall-$([guid]::NewGuid().ToString('N'))" +) + +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion -lt [version]'7.6') { + throw 'Azure PowerShell preview modules require PowerShell 7.6 or later. See https://aka.ms/powershell.' +} +if (Get-PSRepository -Name $RepositoryName -ErrorAction SilentlyContinue) { + throw "Repository '$RepositoryName' already exists. Choose a different -RepositoryName." +} + +$stage = Join-Path ([IO.Path]::GetTempPath()) "azps-install-$([guid]::NewGuid().ToString('N'))" +try { + $release = Invoke-RestMethod 'https://api.github.com/repos/Azure/azclips/releases/latest' -ErrorAction Stop + if ([string]::IsNullOrWhiteSpace($release.tag_name)) { + throw 'Release metadata has no valid tag_name.' + } + $assets = @($release.assets | Where-Object { $_.name -like 'azps-cmdlets-*.tar.gz' }) + if ($assets.Count -ne 1) { + throw "Expected exactly one PowerShell bundle in release $($release.tag_name); found $($assets.Count)." + } + $asset = $assets[0] + if ($asset.name -notmatch '^[A-Za-z0-9._-]+$' -or $asset.browser_download_url -notmatch '^https://') { + throw 'PowerShell bundle has an invalid filename or HTTPS download URL.' + } + Write-Host "Release : $($release.tag_name)" + Write-Host "Asset : $($asset.name)" + New-Item -ItemType Directory -Path $stage | Out-Null + $archive = Join-Path $stage $asset.name + Invoke-WebRequest $asset.browser_download_url -OutFile $archive -ErrorAction Stop + tar -xzf $archive -C $stage + if ($LASTEXITCODE -ne 0) { throw 'PowerShell bundle extraction failed.' } + $installer = Join-Path $stage 'InstallModule.ps1' + if (-not (Test-Path -LiteralPath $installer -PathType Leaf)) { + throw 'PowerShell bundle does not contain InstallModule.ps1.' + } + $options = @{ + ModuleName = $ModuleName + Scope = $Scope + RepositoryName = $RepositoryName + } + if ($RequiredVersion) { $options.RequiredVersion = $RequiredVersion } + & $installer @options +} +finally { + # The bundled helper registers this staging directory as a local repository. + try { + $repository = Get-PSRepository -Name $RepositoryName -ErrorAction SilentlyContinue + if ($repository -and ([string]$repository.SourceLocation).TrimEnd('/', '\') -eq $stage.TrimEnd('/', '\')) { + Unregister-PSRepository -Name $RepositoryName -ErrorAction Stop + } + } + finally { + if (Test-Path -LiteralPath $stage) { + Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction Stop + } + } +} +} @args