diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml index 949ef9a5009..7b9407713b9 100644 --- a/.github/workflows/winget.yml +++ b/.github/workflows/winget.yml @@ -23,8 +23,8 @@ jobs: run: | # Get installer info from release event $assets = '${{ toJSON(github.event.release.assets) }}' | ConvertFrom-Json - $x64InstallerUrl = $assets | Where-Object -Property name -like '*x86_64-windows.zip' | Select-Object -ExpandProperty browser_download_url - $arm64InstallerUrl = $assets | Where-Object -Property name -like '*aarch64-windows.zip' | Select-Object -ExpandProperty browser_download_url + $x64InstallerUrl = $assets | Where-Object -Property name -like '*x86_64-windows-setup.exe' | Select-Object -ExpandProperty browser_download_url + $arm64InstallerUrl = $assets | Where-Object -Property name -like '*aarch64-windows-setup.exe' | Select-Object -ExpandProperty browser_download_url $packageVersion = (${{ toJSON(github.event.release.tag_name) }}).Trim('v') # Update package using wingetcreate diff --git a/.pipelines/nuget.config b/.pipelines/nuget.config new file mode 100644 index 00000000000..e606bb1ab07 --- /dev/null +++ b/.pipelines/nuget.config @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/.pipelines/packages.config b/.pipelines/packages.config new file mode 100644 index 00000000000..0832ece7ea7 --- /dev/null +++ b/.pipelines/packages.config @@ -0,0 +1,5 @@ + + + + + diff --git a/.pipelines/release.yml b/.pipelines/release.yml index 704e6eaf887..83ff3912f73 100644 --- a/.pipelines/release.yml +++ b/.pipelines/release.yml @@ -48,6 +48,8 @@ extends: parameters: featureFlags: autoBaseline: false + networkisolation: + policy: Good WindowsHostVersion: Version: 2022 Network: R1 @@ -107,6 +109,11 @@ extends: CARGO_TARGET_DIR: C:\cargo_target_dir # RUSTC_BOOTSTRAP=1 is required to make panic-immediate-abort work. RUSTC_BOOTSTRAP: 1 + # `nuget install -ExcludeVersion` (see packages.config) + # restores Inno Setup here. + InnoSetupRoot: "$(Build.SourcesDirectory)\\.tools\\Tools.InnoSetup\\tools" + # Holds the uninstaller .e32 that ISCC embeds into every installer. + SignedUninstallerDir: "$(Build.SourcesDirectory)\\.signing" steps: # NOTE: Step objects have ordered keys and you MUST have "task" as the first key. # Objects with ordered keys... lol @@ -133,6 +140,28 @@ extends: # fetching entirely prior to the build, and perform the build with the network disabled. - script: cargo fetch --config .cargo/release-windows-ms.toml displayName: Fetch crates + # Pull Inno Setup from the internal Azure Artifacts NuGet feed. + - task: NuGetAuthenticate@1 + displayName: Authenticate with Azure Artifacts (nuget) + - task: NuGetCommand@2 + displayName: Restore Inno Setup + inputs: + command: custom + arguments: >- + install $(Build.SourcesDirectory)/.pipelines/packages.config + -ConfigFile $(Build.SourcesDirectory)/.pipelines/nuget.config + -OutputDirectory $(Build.SourcesDirectory)/.tools + -ExcludeVersion + -NonInteractive + # Used for `ob_createvpack_version`, the installer version and all output file names. + - pwsh: |- + $cargoToml = Get-Content -Raw -LiteralPath crates/edit/Cargo.toml + $m = [regex]::Match($cargoToml, '(?m)^version\s*=\s*"([^"]+)"') + if (-not $m.Success) { throw 'Failed to extract version from crates/edit/Cargo.toml' } + $version = $m.Groups[1].Value + Write-Host "Edit version: $version" + Write-Host "##vso[task.setvariable variable=EditVersion]$version" + displayName: Set EditVersion - ${{ each platform in parameters.buildPlatforms }}: - script: cargo build --config .cargo/release-windows-ms.toml --frozen --release --target ${{platform}} displayName: Build ${{platform}} Release @@ -149,14 +178,6 @@ extends: contents: | *.exe *.pdb - # Extract the version for `ob_createvpack_version`. - - script: |- - @echo off - for /f "tokens=3 delims=- " %%x in ('findstr /c:"version = " crates\edit\Cargo.toml') do ( - echo ##vso[task.setvariable variable=EditVersion]%%~x - goto :EOF - ) - displayName: "Set EditVersion" - task: onebranch.pipeline.signing@1 displayName: "Sign files" inputs: @@ -166,19 +187,102 @@ extends: search_root: "$(ob_createvpack_vpackdirectory)" use_testsign: false in_container: true - - ${{ each platform in parameters.buildPlatforms }}: - - pwsh: |- - $Dest = New-Item -Type Directory "_staging/${env:RELEASE_NAME}" - Write-Host "Staging files from ${env:VPACK_ROOT} at $Dest" - Get-ChildItem "${env:VPACK_ROOT}\*" -Include *.exe, *.pdb | Copy-Item -Destination $Dest -Verbose - tar.exe -c -v --format=zip -f "$(ob_outputDirectory)\${env:RELEASE_NAME}.zip" -C _staging $env:RELEASE_NAME - env: - RELEASE_NAME: edit-$(EditVersion)-${{ replace(platform, 'pc-windows-msvc', 'windows') }} - ${{ if eq(platform, 'i686-pc-windows-msvc') }}: - VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/i386" - ${{ elseif eq(platform, 'x86_64-pc-windows-msvc') }}: - VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/amd64" - ${{ else }}: # aarch64-pc-windows-msvc - VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/arm64" - displayName: Produce ${{platform}} release archive + - pwsh: |- + $Dest = New-Item -Type Directory "_staging/${env:RELEASE_NAME}" + Write-Host "Staging files from ${env:VPACK_ROOT} at $Dest" + Get-ChildItem "${env:VPACK_ROOT}\*" -Include *.exe, *.pdb | Copy-Item -Destination $Dest -Verbose + tar.exe -c -v --format=zip -f "$(ob_outputDirectory)\${env:RELEASE_NAME}.zip" -C _staging $env:RELEASE_NAME + env: + RELEASE_NAME: edit-$(EditVersion)-${{ replace(platform, 'pc-windows-msvc', 'windows') }} + ${{ if eq(platform, 'i686-pc-windows-msvc') }}: + VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/i386" + ${{ elseif eq(platform, 'x86_64-pc-windows-msvc') }}: + VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/amd64" + ${{ else }}: # aarch64-pc-windows-msvc + VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/arm64" + displayName: Produce ${{platform}} release archive + # Run ISCC once to generate the uninstaller .e32 file. + # Rename .e32 → .exe so the signing task recognises the extension. + # The .e32 is arch-independent (confirmed: same hash for x64 and arm64). + # ISCC exits with code 1 because the file isn't signed yet. + - pwsh: |- + $iscc = Join-Path $env:InnoSetupRoot 'ISCC.exe' + if (-not (Test-Path -LiteralPath $iscc)) { + throw "ISCC.exe not found at $iscc" + } + New-Item -ItemType Directory -Force -Path $env:SIGNED_UNINSTALLER_DIR | Out-Null + $ErrorActionPreference = 'Continue' + & $iscc /Q ` + "/DSignedUninstallerDir=$env:SIGNED_UNINSTALLER_DIR" ` + "/DAppVersion=$env:EDIT_VERSION" ` + "/DArchitecturesAllowed=x64os" ` + "/DSource=$env:VPACK_ROOT\edit.exe" ` + "/O$env:AGENT_TEMPDIRECTORY" ` + /Fdummy ` + assets\edit.iss + $ErrorActionPreference = 'Stop' + $e32 = Get-ChildItem "$env:SIGNED_UNINSTALLER_DIR\*.e32" | Select-Object -First 1 + if (-not $e32) { throw 'ISCC did not produce an uninstaller; see its output above' } + Write-Host "Generated uninstaller: $($e32.Name)" + Rename-Item $e32.FullName ($e32.FullName -replace '\.e32$', '.exe') + exit 0 + env: + EDIT_VERSION: $(EditVersion) + SIGNED_UNINSTALLER_DIR: $(SignedUninstallerDir) + VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/amd64" + displayName: Generate unsigned uninstaller + - task: onebranch.pipeline.signing@1 + displayName: Sign uninstaller + inputs: + command: sign + signing_profile: external_distribution + files_to_sign: "*.exe" + search_root: "$(SignedUninstallerDir)" + use_testsign: false + in_container: true + # Run ISCC a second time to create the actual installers. + - ${{ each platform in parameters.buildPlatforms }}: + - pwsh: |- + # ISCC only accepts the signed uninstaller under its original .e32 extension. + Get-ChildItem "$env:SIGNED_UNINSTALLER_DIR\*.exe" | ForEach-Object { + Rename-Item $_.FullName ($_.FullName -replace '\.exe$', '.e32') + } + + $iscc = Join-Path $env:InnoSetupRoot 'ISCC.exe' + New-Item -ItemType Directory -Force -Path $env:OB_OUTPUTDIRECTORY | Out-Null + & $iscc ` + /Q ` + "/DSignedUninstallerDir=$env:SIGNED_UNINSTALLER_DIR" ` + "/DAppVersion=$env:EDIT_VERSION" ` + "/DArchitecturesAllowed=$env:ARCH" ` + "/DSource=$env:VPACK_ROOT\edit.exe" ` + "/O$env:OB_OUTPUTDIRECTORY" ` + "/F$env:OUTPUT_BASENAME" ` + assets\edit.iss + if ($LASTEXITCODE -ne 0) { throw "ISCC failed with exit code $LASTEXITCODE" } + Write-Host "Produced $env:OB_OUTPUTDIRECTORY\$env:OUTPUT_BASENAME.exe" + env: + EDIT_VERSION: $(EditVersion) + OB_OUTPUTDIRECTORY: $(ob_outputDirectory) + OUTPUT_BASENAME: edit-$(EditVersion)-${{ replace(platform, 'pc-windows-msvc', 'windows') }}-setup + SIGNED_UNINSTALLER_DIR: $(SignedUninstallerDir) + ${{ if eq(platform, 'i686-pc-windows-msvc') }}: + ARCH: x86 + VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/i386" + ${{ elseif eq(platform, 'x86_64-pc-windows-msvc') }}: + ARCH: x64os + VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/amd64" + ${{ else }}: # aarch64-pc-windows-msvc + ARCH: arm64 + VPACK_ROOT: "$(ob_createvpack_vpackdirectory)/arm64" + displayName: Build ${{platform}} installer + - task: onebranch.pipeline.signing@1 + displayName: Sign installers + inputs: + command: sign + signing_profile: external_distribution + files_to_sign: "edit-*.exe" + search_root: "$(ob_outputDirectory)" + use_testsign: false + in_container: true diff --git a/assets/build-windows-setup.ps1 b/assets/build-windows-setup.ps1 new file mode 100644 index 00000000000..450895a742d --- /dev/null +++ b/assets/build-windows-setup.ps1 @@ -0,0 +1,35 @@ +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true + +Set-Location (Split-Path -Parent $PSScriptRoot) + +$env:RUSTC_BOOTSTRAP = 1 +$config = "./.cargo/release.toml" + +if (Get-Command "msrustup" -ErrorAction SilentlyContinue) { + # The default C2/MSVC toolchain cannot compile this project. + $env:MSRUSTUP_TOOLCHAIN = "ms-prod@llvm" + $config = "./.cargo/release-windows-ms.toml" +} + +# Extract the package version from Cargo.toml so we can stamp it into the installer. +$cargoToml = Get-Content -Raw -LiteralPath "crates/edit/Cargo.toml" +$versionMatch = [regex]::Match($cargoToml, '(?m)^version\s*=\s*"([^"]+)"') +if (!$versionMatch.Success) { + throw "Failed to extract version from crates/edit/Cargo.toml" +} +$version = $versionMatch.Groups[1].Value + +cargo build --config $config --release --target aarch64-pc-windows-msvc +cargo build --config $config --release --target x86_64-pc-windows-msvc + +$iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" +if (!(Test-Path $iscc)) { + $iscc = "$env:LocalAppData\Programs\Inno Setup 6\ISCC.exe" + if (!(Test-Path $iscc)) { + throw "Please install Inno Setup 6: https://jrsoftware.org/isdl.php" + } +} + +& $iscc /DAppVersion=$version /DArchitecturesAllowed=arm64 /DSource=$PWD\target\aarch64-pc-windows-msvc\release\edit.exe /O$PWD\target /Fedit-$version-aarch64-windows-setup assets\edit.iss +& $iscc /DAppVersion=$version /DArchitecturesAllowed=x64os /DSource=$PWD\target\x86_64-pc-windows-msvc\release\edit.exe /O$PWD\target /Fedit-$version-x86_64-windows-setup assets\edit.iss diff --git a/assets/edit.iss b/assets/edit.iss new file mode 100644 index 00000000000..68c0e3f2890 --- /dev/null +++ b/assets/edit.iss @@ -0,0 +1,177 @@ +#ifndef AppVersion + #define AppVersion "0.0.0" +#endif + +[Setup] +AppId={{1717C176-3A2F-4E01-83C4-916424E34160} +AppName=Microsoft Edit +DefaultGroupName=Microsoft Edit +AppVersion={#AppVersion} +AppPublisher=Microsoft Corporation +AppPublisherURL=https://github.com/microsoft/edit +AppSupportURL=https://github.com/microsoft/edit +AppUpdatesURL=https://github.com/microsoft/edit +SetupMutex=microsoft-edit-setup +DefaultDirName={autopf}\Microsoft Edit +DisableDirPage=yes +DisableProgramGroupPage=yes +SetupIconFile=edit.ico +UninstallDisplayIcon={app}\msedit.exe +MinVersion=10.0 +ArchitecturesAllowed={#ArchitecturesAllowed} +#if ArchitecturesAllowed != "x86" +ArchitecturesInstallIn64BitMode={#ArchitecturesAllowed} +#endif +PrivilegesRequired=admin +ChangesEnvironment=yes +SolidCompression=yes +WizardStyle=modern dynamic +OutputBaseFilename=edit + +#ifdef SignedUninstallerDir +SignedUninstaller=yes +SignedUninstallerDir={#SignedUninstallerDir} +#endif + +[Tasks] +Name: "path"; Description: "Add msedit to the system &PATH" +Name: "path\edit"; Description: "Also provide it as &edit, taking precedence over the edit.exe shipped with Windows" + +[Files] +Source: {#Source}; DestDir: "{app}"; DestName: "msedit.exe"; Flags: notimestamp ignoreversion + +; Just in case, ensure that the install dir is in a clean state. +; This also ensures that edit is not currently being used. :) +[InstallDelete] +Type: filesandordirs; Name: "{app}" + +[UninstallDelete] +Type: filesandordirs; Name: "{app}" + +[Code] +function CreateHardLink(lpFileName, lpExistingFileName: String; lpSecurityAttributes: LongWord): Boolean; +external 'CreateHardLinkW@kernel32.dll stdcall'; + +const + ENV_KEY = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'; + +var + g_AppDirPath: String; + g_MseditExePath: String; + g_EditExePath: String; + +procedure InitializeGlobals; +begin + g_AppDirPath := ExpandConstant('{app}'); + g_MseditExePath := ExpandConstant('{app}\msedit.exe'); + g_EditExePath := ExpandConstant('{app}\edit.exe'); +end; + +procedure CreateHardlinks; +begin + if not CreateHardLink(g_EditExePath, g_MseditExePath, 0) then + RaiseException('Failed to create hardlink for edit.exe'); +end; + +// Install=False removes us from the PATH. Prepend=True places us ahead of +// System32 so that our edit.exe wins over the one shipped with Windows. +procedure ModifyPath(Install, Prepend: Boolean); +var + PathsBefore, PathsAfter: TArrayOfString; + PathsStringBefore, System32Path: String; + I, Count, System32Index, AppPathIndex: Integer; +begin + if not RegQueryStringValue(HKLM, ENV_KEY, 'Path', PathsStringBefore) then + RaiseException('Failed to read system PATH'); + + PathsBefore := StringSplit(PathsStringBefore, [';'], stExcludeEmpty); + if GetArrayLength(PathsBefore) = 0 then + RaiseException('Failed to parse system PATH'); + + System32Index := -1; + AppPathIndex := -1; + if Install and Prepend then + begin + System32Path := ExpandConstant('{sys}'); + + // Find the index of System32. + System32Index := 0; + for I := 0 to GetArrayLength(PathsBefore) - 1 do + begin + if PathStartsWith(PathsBefore[I], '%SystemRoot%\System32', True) or + PathStartsWith(PathsBefore[I], System32Path, True) then + begin + System32Index := I; + Break; + end; + end; + + // Find the index of our app path, if any. We want to retain the same + // index between installations. Unless it was previously past System32. + // We want it to be always before System32. + AppPathIndex := 0; + for I := 0 to System32Index - 1 do + begin + if PathStartsWith(PathsBefore[I], g_AppDirPath, True) then + begin + AppPathIndex := I; + Break; + end; + end; + end; + + // Remove any and all paths pointing to our app. + // This doubles as an uninstall path. + SetArrayLength(PathsAfter, GetArrayLength(PathsBefore) + 1); + Count := 0; + for I := 0 to GetArrayLength(PathsBefore) - 1 do + begin + if I = AppPathIndex then + begin + PathsAfter[Count] := g_AppDirPath; + Count := Count + 1; + end; + if not PathStartsWith(PathsBefore[I], g_AppDirPath, True) then + begin + PathsAfter[Count] := PathsBefore[I]; + Count := Count + 1; + end; + end; + + // Otherwise we're happy to sit at the very end of the PATH. + if Install and (not Prepend) then + begin + PathsAfter[Count] := g_AppDirPath; + Count := Count + 1; + end; + + SetArrayLength(PathsAfter, Count); + + if not RegWriteExpandStringValue(HKLM, ENV_KEY, 'Path', StringJoin(';', PathsAfter)) then + RaiseException('Failed to write system PATH'); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + Prepend: Boolean; +begin + if CurStep = ssPostInstall then + begin + InitializeGlobals; + + Prepend := WizardIsTaskSelected('path\edit'); + if Prepend then + CreateHardlinks; + + ModifyPath(WizardIsTaskSelected('path'), Prepend); + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then + begin + InitializeGlobals; + ModifyPath(False, False); + end; +end;