From 9fac51722653fa0b0b6a5786166fb18c763424e0 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 18 Aug 2026 22:39:38 +0300 Subject: feat: make Windows generally available --- scripts/changelog.py | 57 ++++++++-- scripts/test_changelog.py | 50 ++++++--- scripts/windows_install_conpty_package_test.ps1 | 143 ++++++++++++++++++++---- 3 files changed, 206 insertions(+), 44 deletions(-) (limited to 'scripts') diff --git a/scripts/changelog.py b/scripts/changelog.py index 4d023ccc..9a96ea4e 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -19,13 +19,17 @@ DEFAULT_RELEASE_REPO = "herdrdev/herdr" DEFAULT_LATEST_JSON_PATH = Path("website/latest.json") DEFAULT_PRODUCT_ANNOUNCEMENT_PATH = Path("docs/next/product-announcement.json") PROTOCOL_SOURCE_PATH = Path("src/protocol/wire.rs") -ASSET_TARGETS = ( +CORE_ASSET_TARGETS = ( "linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", ) -EXPECTED_ASSET_NAMES = {target: f"herdr-{target}" for target in ASSET_TARGETS} +ASSET_TARGETS = (*CORE_ASSET_TARGETS, "windows-x86_64") +EXPECTED_ASSET_NAMES = { + **{target: f"herdr-{target}" for target in CORE_ASSET_TARGETS}, + "windows-x86_64": "herdr-windows-x86_64.zip", +} @dataclass(frozen=True) @@ -169,16 +173,22 @@ def infer_protocol_from_notes(notes: str) -> int | None: return int(match.group(1)) -def normalize_assets(value: Any, label: str) -> dict[str, str]: +def normalize_assets( + value: Any, + label: str, + required_targets: tuple[str, ...] = ASSET_TARGETS, +) -> dict[str, str]: if not isinstance(value, dict): raise ChangelogError(f"{label} must be an object") - missing_targets = [target for target in ASSET_TARGETS if target not in value] + missing_targets = [target for target in required_targets if target not in value] if missing_targets: raise ChangelogError(f"{label} is missing asset URL for {', '.join(missing_targets)}") normalized_assets: dict[str, str] = {} for target in ASSET_TARGETS: + if target not in value: + continue url = value.get(target) if not isinstance(url, str) or not url.strip(): raise ChangelogError(f"{label} is missing asset URL for {target}") @@ -186,12 +196,22 @@ def normalize_assets(value: Any, label: str) -> dict[str, str]: return normalized_assets -def normalize_sha256(value: Any, label: str) -> dict[str, str]: +def normalize_sha256( + value: Any, + label: str, + required_targets: tuple[str, ...] = ASSET_TARGETS, +) -> dict[str, str]: if not isinstance(value, dict): raise ChangelogError(f"{label} must be an object") + missing_targets = [target for target in required_targets if target not in value] + if missing_targets: + raise ChangelogError(f"{label} has invalid SHA-256 for {missing_targets[0]}") + checksums: dict[str, str] = {} for target in ASSET_TARGETS: + if target not in value: + continue checksum = value.get(target) if not isinstance(checksum, str) or re.fullmatch( r"[0-9a-fA-F]{64}", checksum.strip() @@ -225,11 +245,19 @@ def normalize_release_metadata(value: Any, label: str, version: str) -> dict[str if inferred_protocol is not None: metadata["protocol"] = inferred_protocol if "assets" in value: - metadata["assets"] = normalize_assets(value.get("assets"), f"{label}.assets") + metadata["assets"] = normalize_assets( + value.get("assets"), + f"{label}.assets", + required_targets=CORE_ASSET_TARGETS, + ) else: metadata["assets"] = default_release_assets(version) if "sha256" in value: - metadata["sha256"] = normalize_sha256(value.get("sha256"), f"{label}.sha256") + metadata["sha256"] = normalize_sha256( + value.get("sha256"), + f"{label}.sha256", + required_targets=CORE_ASSET_TARGETS, + ) announcement = normalize_announcement(value.get("announcement"), label) if announcement is not None: metadata["announcement"] = announcement @@ -306,11 +334,12 @@ def build_latest_json( def default_release_assets(version: str, repo: str = DEFAULT_RELEASE_REPO) -> dict[str, str]: + """Return legacy release URLs for versions published before Windows stable assets.""" normalized_version = normalize_version(version) tag = f"v{normalized_version}" return { target: f"https://github.com/{repo}/releases/download/{tag}/{EXPECTED_ASSET_NAMES[target]}" - for target in ASSET_TARGETS + for target in CORE_ASSET_TARGETS } @@ -468,12 +497,20 @@ def archived_releases_from_current_manifest(manifest: dict[str, Any]) -> dict[st metadata["protocol"] = protocol assets = manifest.get("assets") if isinstance(assets, dict): - metadata["assets"] = normalize_assets(assets, "current root assets") + metadata["assets"] = normalize_assets( + assets, + "current root assets", + required_targets=CORE_ASSET_TARGETS, + ) else: metadata["assets"] = default_release_assets(normalized_version) sha256 = manifest.get("sha256") if isinstance(sha256, dict): - metadata["sha256"] = normalize_sha256(sha256, "current root sha256") + metadata["sha256"] = normalize_sha256( + sha256, + "current root sha256", + required_targets=CORE_ASSET_TARGETS, + ) announcement = normalize_announcement(manifest.get("announcement"), "current root") if announcement is not None: metadata["announcement"] = announcement diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py index ad384734..6a9d12dd 100644 --- a/scripts/test_changelog.py +++ b/scripts/test_changelog.py @@ -24,20 +24,33 @@ from scripts.changelog import ( ) +def release_assets(version: str) -> dict[str, str]: + normalized = version.removeprefix("v") + return { + **default_release_assets(normalized), + "windows-x86_64": f"https://github.com/herdrdev/herdr/releases/download/v{normalized}/herdr-windows-x86_64.zip", + } + + def release_sha256() -> dict[str, str]: return { "linux-x86_64": "a" * 64, "linux-aarch64": "b" * 64, "macos-x86_64": "c" * 64, "macos-aarch64": "d" * 64, + "windows-x86_64": "e" * 64, } def release_assets_with_digests() -> list[dict[str, str]]: return [ { - "name": f"herdr-{target}", - "url": f"https://example.com/{target}", + "name": ( + "herdr-windows-x86_64.zip" + if target == "windows-x86_64" + else f"herdr-{target}" + ), + "url": f"https://example.com/{target}{'.zip' if target == 'windows-x86_64' else ''}", "digest": f"sha256:{digest * 64}", } for target, digest in ( @@ -45,6 +58,7 @@ def release_assets_with_digests() -> list[dict[str, str]]: ("linux-aarch64", "b"), ("macos-x86_64", "c"), ("macos-aarch64", "d"), + ("windows-x86_64", "e"), ) ] @@ -79,7 +93,7 @@ class ChangelogScriptTests(unittest.TestCase): build_latest_json( "0.1.1", "\n### Fixed\n- One\n\n", - default_release_assets("0.1.1"), + release_assets("0.1.1"), release_sha256(), ) ) @@ -92,7 +106,7 @@ class ChangelogScriptTests(unittest.TestCase): build_latest_json( "v0.1.1", "### Fixed\n- Smoothed Claude flapping.\n", - default_release_assets("0.1.1"), + release_assets("0.1.1"), release_sha256(), ) ) @@ -107,6 +121,7 @@ class ChangelogScriptTests(unittest.TestCase): "linux-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.1.1/herdr-linux-aarch64", "macos-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.1.1/herdr-macos-x86_64", "macos-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.1.1/herdr-macos-aarch64", + "windows-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.1.1/herdr-windows-x86_64.zip", }, ) self.assertEqual(manifest["releases"]["0.1.1"]["assets"], manifest["assets"]) @@ -118,7 +133,7 @@ class ChangelogScriptTests(unittest.TestCase): build_latest_json( "0.1.1", "### Fixed\n- One", - default_release_assets("0.1.1"), + release_assets("0.1.1"), release_sha256(), announcement={"id": "keybinding-v2", "title": "Keybind Refactor", "body": "body"}, ) @@ -138,7 +153,7 @@ class ChangelogScriptTests(unittest.TestCase): build_latest_json( "0.1.2", "### Fixed\n- Two", - default_release_assets("0.1.2"), + release_assets("0.1.2"), release_sha256(), releases={"0.1.1": {"notes": "### Fixed\n- One"}}, ) @@ -147,7 +162,7 @@ class ChangelogScriptTests(unittest.TestCase): self.assertEqual(list(manifest["releases"]), ["0.1.2", "0.1.1"]) self.assertEqual(manifest["releases"]["0.1.2"]["notes"], "### Fixed\n- Two") self.assertEqual(manifest["releases"]["0.1.2"]["protocol"], read_protocol_version()) - self.assertEqual(manifest["releases"]["0.1.2"]["assets"], default_release_assets("0.1.2")) + self.assertEqual(manifest["releases"]["0.1.2"]["assets"], release_assets("0.1.2")) self.assertEqual(manifest["releases"]["0.1.1"]["notes"], "### Fixed\n- One") self.assertEqual(manifest["releases"]["0.1.1"]["assets"], default_release_assets("0.1.1")) @@ -157,7 +172,7 @@ class ChangelogScriptTests(unittest.TestCase): build_latest_json( "0.1.2", "### Fixed\n- Two", - default_release_assets("0.1.2"), + release_assets("0.1.2"), release_sha256(), releases={"0.1.1": {"notes": "### Fixed\n- One", "assets": assets}}, ) @@ -170,7 +185,7 @@ class ChangelogScriptTests(unittest.TestCase): build_latest_json( "0.1.2", "### Fixed\n- Two", - default_release_assets("0.1.2"), + release_assets("0.1.2"), release_sha256(), releases={"0.1.1": {"notes": "### Fixed\n- One", "protocol": 7}}, ) @@ -183,7 +198,7 @@ class ChangelogScriptTests(unittest.TestCase): build_latest_json( "0.1.2", "### Fixed\n- Two", - default_release_assets("0.1.2"), + release_assets("0.1.2"), release_sha256(), releases={ "0.1.1": { @@ -337,6 +352,7 @@ class ChangelogScriptTests(unittest.TestCase): "linux-aarch64": "https://example.com/linux-aarch64", "macos-x86_64": "https://example.com/macos-x86_64", "macos-aarch64": "https://example.com/macos-aarch64", + "windows-x86_64": "https://example.com/windows-x86_64.zip", }, "sha256": release_sha256(), }, @@ -426,6 +442,7 @@ class ChangelogScriptTests(unittest.TestCase): "linux-aarch64": "https://example.com/linux-aarch64", "macos-x86_64": "https://example.com/macos-x86_64", "macos-aarch64": "https://example.com/macos-aarch64", + "windows-x86_64": "https://example.com/windows-x86_64.zip", }, "sha256": release_sha256(), } @@ -438,6 +455,7 @@ class ChangelogScriptTests(unittest.TestCase): "linux-aarch64": "https://example.com/linux-aarch64", "macos-x86_64": "https://example.com/macos-x86_64", "macos-aarch64": "https://example.com/macos-aarch64", + "windows-x86_64": "https://example.com/windows-x86_64.zip", }, "sha256": release_sha256(), } @@ -446,7 +464,7 @@ class ChangelogScriptTests(unittest.TestCase): self.assertEqual(canonical, expected) def test_current_release_assets_must_be_mirrored(self) -> None: - assets = default_release_assets("0.1.1") + assets = release_assets("0.1.1") ensure_current_release_assets_are_mirrored( { "version": "0.1.1", @@ -472,12 +490,12 @@ class ChangelogScriptTests(unittest.TestCase): "version": "0.1.1", "protocol": read_protocol_version(), "notes": "### Fixed\n- One", - "assets": default_release_assets("0.1.1"), + "assets": release_assets("0.1.1"), "sha256": release_sha256(), "releases": { "0.1.1": { "notes": "### Fixed\n- One", - "assets": default_release_assets("0.1.0"), + "assets": release_assets("0.1.0"), "sha256": release_sha256(), } }, @@ -497,6 +515,7 @@ class ChangelogScriptTests(unittest.TestCase): "linux-aarch64": "https://example.com/linux-aarch64", "macos-x86_64": "https://example.com/macos-x86_64", "macos-aarch64": "https://example.com/macos-aarch64", + "windows-x86_64": "https://example.com/windows-x86_64.zip", }, "sha256": release_sha256(), }, @@ -509,6 +528,7 @@ class ChangelogScriptTests(unittest.TestCase): "linux-aarch64": "https://example.com/linux-aarch64", "macos-x86_64": "https://example.com/macos-x86_64", "macos-aarch64": "https://example.com/macos-aarch64", + "windows-x86_64": "https://example.com/windows-x86_64.zip", }, "sha256": release_sha256(), }, @@ -531,14 +551,14 @@ class ChangelogScriptTests(unittest.TestCase): "version": "0.1.1", "protocol": read_protocol_version() + 1, "notes": "### Fixed\n- One", - "assets": default_release_assets("0.1.1"), + "assets": release_assets("0.1.1"), "sha256": release_sha256(), } expected = { "version": "0.1.1", "protocol": read_protocol_version(), "notes": "### Fixed\n- One", - "assets": default_release_assets("0.1.1"), + "assets": release_assets("0.1.1"), "sha256": release_sha256(), } diff --git a/scripts/windows_install_conpty_package_test.ps1 b/scripts/windows_install_conpty_package_test.ps1 index 95b31c4a..f4c4fc60 100644 --- a/scripts/windows_install_conpty_package_test.ps1 +++ b/scripts/windows_install_conpty_package_test.ps1 @@ -82,7 +82,7 @@ $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopba $listener.Start() $port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port $listener.Stop() -$manifest = @{ +$previewManifest = @{ channel = "preview" base_version = "0.0.0" build_id = "installer-test" @@ -94,18 +94,35 @@ $manifest = @{ } } } | ConvertTo-Json -Depth 5 -$manifestPath = Join-Path $webRoot "preview.json" -$manifest | Out-File -LiteralPath $manifestPath -Encoding utf8 +$legacyStableManifest = @{ + version = "0.0.0" + assets = @{} +} | ConvertTo-Json -Depth 5 +$stableManifest = @{ + version = "0.0.1" + assets = @{ + "windows-x86_64" = "http://127.0.0.1:$port/herdr-windows-x86_64.zip" + } + sha256 = @{ + "windows-x86_64" = $hash + } +} | ConvertTo-Json -Depth 5 +$previewManifestPath = Join-Path $webRoot "preview.json" +$stableManifestPath = Join-Path $webRoot "latest.json" +$previewManifest | Out-File -LiteralPath $previewManifestPath -Encoding utf8 +$legacyStableManifest | Out-File -LiteralPath $stableManifestPath -Encoding utf8 $server = $null $oldHerdrHome = $env:HERDR_HOME +$oldProcessPath = $env:Path try { $server = Start-Process python -ArgumentList @("-m", "http.server", "$port", "--bind", "127.0.0.1", "--directory", $webRoot) -PassThru -WindowStyle Hidden $env:HERDR_HOME = Join-Path $root "unused\..\home" - $manifestUrl = "http://127.0.0.1:$port/preview.json" + $previewManifestUrl = "http://127.0.0.1:$port/preview.json" + $stableManifestUrl = "http://127.0.0.1:$port/latest.json" for ($attempt = 0; $attempt -lt 20; $attempt++) { try { - Invoke-WebRequest -Uri $manifestUrl -UseBasicParsing | Out-Null + Invoke-WebRequest -Uri $stableManifestUrl -UseBasicParsing | Out-Null break } catch { if ($attempt -eq 19) { throw } @@ -113,8 +130,31 @@ try { } } + $freshStableHome = Join-Path $root "fresh-stable-home" + $freshStableBin = Join-Path $root "fresh-stable-bin" + $stableManifest | Out-File -LiteralPath $stableManifestPath -Encoding utf8 + $env:HERDR_HOME = $freshStableHome + $env:Path = $oldProcessPath + & $installerPath ` + -ManifestUrl $stableManifestUrl ` + -InstallDir $freshStableBin + $freshStableRelease = Get-ChildItem -LiteralPath (Join-Path $freshStableHome "packages\standalone\releases") -Directory | + Where-Object { $_.Name.StartsWith("0.0.1-") } | + Select-Object -First 1 + if ($null -eq $freshStableRelease) { + throw "fresh installer did not default to the stable Windows package" + } + + $legacyStableManifest | Out-File -LiteralPath $stableManifestPath -Encoding utf8 + $env:HERDR_HOME = Join-Path $root "unused\..\home" + $env:Path = $oldProcessPath + & $installerPath ` + -ManifestUrl $stableManifestUrl ` + -InstallDir $installDir ` + -ExpectedBuildId "installer-test" + # Keep the existing positional web-installer contract, including Retain in slot five. - & "$PSScriptRoot\..\website\install.ps1" "preview" $manifestUrl $installDir "installer-test" 3 + & $installerPath "preview" $previewManifestUrl $installDir "installer-test" 3 $localInstallDir = Join-Path $root "local-bin" $env:HERDR_HOME = Join-Path $root "local-home" @@ -136,7 +176,7 @@ try { $badLocalChecksumRejected = $false try { & $installerPath ` - -ManifestUrl "$manifestUrl/unused" ` + -ManifestUrl "$previewManifestUrl/unused" ` -InstallDir $localInstallDir ` -LocalPackagePath $archive ` -LocalPackageFormat "zip" ` @@ -153,7 +193,7 @@ try { } & $installerPath ` - -ManifestUrl "$manifestUrl/unused" ` + -ManifestUrl "$previewManifestUrl/unused" ` -InstallDir $localInstallDir ` -LocalPackagePath $archive ` -LocalPackageFormat "zip" ` @@ -188,13 +228,14 @@ try { } Remove-Item -LiteralPath (Join-Path $releaseDir.FullName "conpty\conpty.dll") -Force - $badManifest = $manifest | ConvertFrom-Json + $badManifest = $previewManifest | ConvertFrom-Json $badManifest.assets."windows-x86_64".url = "http://127.0.0.1:$port/missing.zip" - $badManifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath $manifestPath -Encoding utf8 + $badManifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath $previewManifestPath -Encoding utf8 $downloadFailed = $false try { & "$PSScriptRoot\..\website\install.ps1" ` - -ManifestUrl $manifestUrl ` + -Channel preview ` + -ManifestUrl $previewManifestUrl ` -InstallDir $installDir ` -ExpectedBuildId "installer-test" } catch { @@ -210,7 +251,7 @@ try { throw "failed repair removed the existing release" } - $manifest | Out-File -LiteralPath $manifestPath -Encoding utf8 + $previewManifest | Out-File -LiteralPath $previewManifestPath -Encoding utf8 $stagedConpty = Join-Path $releasesDir ".staging.$($releaseDir.Name).$PID\conpty\conpty.dll" $transientLockState = @{ Handle = $null; Acquired = $false; Released = $false } @@ -245,8 +286,9 @@ try { }.GetNewClosure() $transientLockBreakpoint = Set-PSBreakpoint -Script $installerPath -Variable "backupDir" -Mode Write -Action $lockStagedFileTransiently try { - & "$PSScriptRoot\..\website\install.ps1" ` - -ManifestUrl $manifestUrl ` + & $installerPath ` + -Channel preview ` + -ManifestUrl $previewManifestUrl ` -InstallDir $installDir ` -ExpectedBuildId "installer-test" if (-not $transientLockState.Acquired) { @@ -285,8 +327,9 @@ try { try { $swapFailed = $false try { - & "$PSScriptRoot\..\website\install.ps1" ` - -ManifestUrl $manifestUrl ` + & $installerPath ` + -Channel preview ` + -ManifestUrl $previewManifestUrl ` -InstallDir $installDir ` -ExpectedBuildId "installer-test" } catch { @@ -317,7 +360,8 @@ try { } & "$PSScriptRoot\..\website\install.ps1" ` - -ManifestUrl $manifestUrl ` + -Channel preview ` + -ManifestUrl $previewManifestUrl ` -InstallDir $installDir ` -ExpectedBuildId "installer-test" if (-not (Test-Path -LiteralPath (Join-Path $installDir "conpty\conpty.dll") -PathType Leaf)) { @@ -329,7 +373,8 @@ try { Move-Item -LiteralPath $x64HostDir -Destination $junctionTarget New-Item -ItemType Junction -Path $x64HostDir -Target $junctionTarget | Out-Null & "$PSScriptRoot\..\website\install.ps1" ` - -ManifestUrl $manifestUrl ` + -Channel preview ` + -ManifestUrl $previewManifestUrl ` -InstallDir $installDir ` -ExpectedBuildId "installer-test" $repairedHostDir = Get-Item -LiteralPath (Join-Path $installDir "conpty\x64") -Force @@ -340,7 +385,8 @@ try { $rejected = $false try { & "$PSScriptRoot\..\website\install.ps1" ` - -ManifestUrl $manifestUrl ` + -Channel preview ` + -ManifestUrl $previewManifestUrl ` -InstallDir $installDir ` -ExpectedBuildId "different-build" } catch { @@ -352,8 +398,67 @@ try { if (-not $rejected) { throw "installer accepted a manifest that did not match the updater-selected build" } + + $stableManifest | Out-File -LiteralPath $stableManifestPath -Encoding utf8 + & "$PSScriptRoot\..\website\install.ps1" ` + -Channel stable ` + -ManifestUrl $stableManifestUrl ` + -InstallDir $installDir + $stableReleaseDir = Get-ChildItem -LiteralPath (Join-Path $herdrHome "packages\standalone\releases") -Directory | + Where-Object { $_.Name.StartsWith("0.0.1-") } | + Select-Object -First 1 + if ($null -eq $stableReleaseDir) { + throw "installer did not install the stable Windows package" + } + foreach ($relative in $required) { + if (-not (Test-Path -LiteralPath (Join-Path $installDir $relative) -PathType Leaf)) { + throw "stable installer did not activate required file $relative" + } + } + + $customPreviewManifestPath = Join-Path $webRoot "candidate.json" + $customPreviewManifest = $previewManifest | ConvertFrom-Json + $customPreviewManifest.PSObject.Properties.Remove("channel") + $customPreviewManifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath $customPreviewManifestPath -Encoding utf8 + $fakeBin = Join-Path $root "fake-existing" + New-Item -ItemType Directory -Force -Path $fakeBin | Out-Null + @' +@echo off +if "%1"=="channel" if "%2"=="show" ( + echo preview + exit /b 0 +) +exit /b 1 +'@ | Out-File -LiteralPath (Join-Path $fakeBin "herdr.cmd") -Encoding ascii + + $preserveHome = Join-Path $root "preserve-home" + $preserveBin = Join-Path $root "preserve-bin" + $env:HERDR_HOME = $preserveHome + $env:Path = "$fakeBin;$oldProcessPath" + & "$PSScriptRoot\..\website\install.ps1" ` + -ManifestUrl "http://127.0.0.1:$port/candidate.json" ` + -InstallDir $preserveBin ` + -ExpectedBuildId "installer-test" + $preservedPreview = Get-ChildItem -LiteralPath (Join-Path $preserveHome "packages\standalone\releases") -Directory | + Where-Object { $_.Name.StartsWith("0.0.0-preview.installer-test-") } | + Select-Object -First 1 + if ($null -eq $preservedPreview) { + throw "installer did not preserve the existing preview channel" + } + + & "$PSScriptRoot\..\website\install.ps1" ` + -Channel stable ` + -ManifestUrl $stableManifestUrl ` + -InstallDir $preserveBin + $explicitStable = Get-ChildItem -LiteralPath (Join-Path $preserveHome "packages\standalone\releases") -Directory | + Where-Object { $_.Name.StartsWith("0.0.1-") } | + Select-Object -First 1 + if ($null -eq $explicitStable) { + throw "explicit stable channel did not override the existing preview channel" + } } finally { $env:HERDR_HOME = $oldHerdrHome + $env:Path = $oldProcessPath if ($null -ne $server -and -not $server.HasExited) { Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue } -- cgit v1.2.3