name: Build and deploy OutlookAddin on: push: branches: [main] tags: ['v*'] pull_request: branches: [main] env: REGISTRY: gitea.dev.marcus-law.co.il CDN_IMAGE_NAME: espocrm-extensions/outlook-addin-cdn PROVIDER_URL: 'https://platform.dev.marcus-law.co.il/outlook-addin/OutlookAddin.vsto' # Force NuGet to install + resolve packages outside C:\Windows\System32\. # NuGet's UserProfile lookup uses SHGetKnownFolderPath which ignores our # USERPROFILE override and returns the LocalSystem profile when the runner # service runs as LocalSystem. 32-bit MSBuild then hits Win32 File System # Redirector and "loses" packages -> NETSDK1064. NUGET_PACKAGES short- # circuits the lookup and keeps everything outside system32. NUGET_PACKAGES: 'C:\actions-home\.nuget\packages' jobs: build: runs-on: windows-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Add Office-capable MSBuild to PATH shell: powershell run: | # microsoft/setup-msbuild@v2 picks `vswhere -latest`, which prefers # Build Tools 2022. Build Tools has no VSTO targets (MSB4226), so # locate a VS installation that explicitly has the Office workload # (the one that ships Microsoft.VisualStudio.Tools.Office.targets). $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" $installPath = & $vswhere -products * -requires Microsoft.VisualStudio.Workload.Office -property installationPath | Select-Object -First 1 if (-not $installPath) { throw "No VS installation with the Office workload found on this runner." } $msbuildDir = Join-Path $installPath 'MSBuild\Current\Bin' Write-Host "Using MSBuild from $msbuildDir" Add-Content -Path $env:GITHUB_PATH -Value $msbuildDir - name: Import code-signing cert into CurrentUser\My # The VSTO ResolveKeySource target reads ManifestCertificateThumbprint # straight from the csproj XML and looks for it under Cert:\CurrentUser\My # (not LocalMachine\My). Import the real Marcus-Law cert there. shell: powershell env: PFX_B64: ${{ secrets.CODESIGN_CERT_PFX_BASE64 }} PFX_PWD: ${{ secrets.CODESIGN_CERT_PASSWORD }} THUMB: ${{ secrets.CODESIGN_THUMBPRINT }} run: | $existing = Get-ChildItem Cert:\CurrentUser\My -ErrorAction SilentlyContinue | Where-Object { $_.Thumbprint -eq $env:THUMB } if ($existing) { Write-Host "Cert already in CurrentUser\My."; return } $pfxBytes = [Convert]::FromBase64String($env:PFX_B64) $tmp = Join-Path $env:TEMP "codesign-$([Guid]::NewGuid()).pfx" [IO.File]::WriteAllBytes($tmp, $pfxBytes) $pwd = ConvertTo-SecureString -String $env:PFX_PWD -Force -AsPlainText Import-PfxCertificate -FilePath $tmp -CertStoreLocation Cert:\CurrentUser\My -Password $pwd | Out-Null Remove-Item -Force $tmp Write-Host "Cert imported into CurrentUser\My." - name: Patch csproj to use real cert thumbprint # VS's compile-time manifest signing reads the thumbprint from the # csproj XML (not from /p: overrides), so we rewrite it in place to # point at our Marcus-Law cert. shell: powershell env: THUMB: ${{ secrets.CODESIGN_THUMBPRINT }} run: | $csproj = 'src/OutlookAddin/OutlookAddin.csproj' $orig = '2399E9BF73820F3B1FC744BCBFA7F82D536E3256' $content = Get-Content $csproj -Raw $patched = $content -replace [regex]::Escape($orig), $env:THUMB if ($patched -eq $content) { Write-Warning "Thumbprint $orig not present in csproj." } else { Set-Content -Path $csproj -Value $patched -NoNewline; Write-Host "Patched: $orig -> $env:THUMB" } - name: Build (restore + compile) # `msbuild /restore` runs restore and build in the same MSBuild process, # so the package cache + project.assets.json + assembly resolution stay # consistent (avoids NETSDK1064). # /flp dumps detailed log to build.log so we can retrieve it via the # Gitea Actions artifacts API even when MSBuild fails mid-target. shell: powershell run: | msbuild OutlookAddin.sln /restore /t:Build /p:Configuration=Release /p:Platform="Any CPU" /m ` /flp:logfile=build.log`;verbosity=detailed ` /flp1:logfile=build-errors.log`;errorsonly ` /flp2:logfile=build-warnings.log`;warningsonly if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Upload build logs if: always() uses: actions/upload-artifact@v4 with: name: build-logs-${{ github.run_id }} path: | build.log build-errors.log build-warnings.log retention-days: 7 - name: Test run: dotnet test src/OutlookAddin.Tests/OutlookAddin.Tests.csproj --configuration Release --no-build --logger "trx;LogFileName=test-results.trx" - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: test-results path: '**/*.trx' publish-clickonce: needs: build runs-on: windows-latest if: startsWith(github.ref, 'refs/tags/v') steps: - name: Checkout uses: actions/checkout@v4 - name: Add Office-capable MSBuild to PATH shell: powershell run: | $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" $installPath = & $vswhere -products * -requires Microsoft.VisualStudio.Workload.Office -property installationPath | Select-Object -First 1 if (-not $installPath) { throw "No VS installation with the Office workload found on this runner." } Add-Content -Path $env:GITHUB_PATH -Value (Join-Path $installPath 'MSBuild\Current\Bin') - name: Resolve mage.exe + signtool.exe paths id: tools shell: powershell run: | # mage.exe lives under .NET 4.8 Developer Pack. Find any installed copy. $mage = Get-ChildItem 'C:\Program Files (x86)\Microsoft SDKs\Windows' -Recurse -Filter mage.exe -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1 if (-not $mage) { throw "mage.exe not found" } # signtool.exe lives under Windows SDK. Pick the highest-version x64 copy. $signtool = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue | Where-Object { $_.FullName -like '*\x64\*' } | Sort-Object { [version](Split-Path -Leaf (Split-Path -Parent (Split-Path -Parent $_.FullName))) } -Descending | Select-Object -First 1 if (-not $signtool) { throw "signtool.exe not found" } Write-Host "MAGE: $($mage.FullName)" Write-Host "SIGNTOOL: $($signtool.FullName)" "MAGE=$($mage.FullName)" | Out-File -Append $env:GITHUB_ENV "SIGNTOOL=$($signtool.FullName)" | Out-File -Append $env:GITHUB_ENV - name: Compute version from tag id: ver shell: powershell run: | $tag = "${{ github.ref_name }}" if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') { throw "Tag $tag is not in vMAJOR.MINOR.PATCH form" } $version = "$($Matches[1]).0" "version=$version" | Out-File -Append $env:GITHUB_OUTPUT "versionDot=$($Matches[1])" | Out-File -Append $env:GITHUB_OUTPUT "versionUscore=$($version -replace '\.','_')" | Out-File -Append $env:GITHUB_OUTPUT Write-Host "Version: $version" - name: Ensure code-signing cert in CurrentUser\My AND LocalMachine\My shell: powershell env: THUMB: ${{ secrets.CODESIGN_THUMBPRINT }} PFX_B64: ${{ secrets.CODESIGN_CERT_PFX_BASE64 }} PFX_PWD: ${{ secrets.CODESIGN_CERT_PASSWORD }} run: | $thumb = $env:THUMB # Import to CurrentUser\My (where VSTO ResolveKeySource looks). $inUser = Get-ChildItem Cert:\CurrentUser\My -ErrorAction SilentlyContinue | Where-Object { $_.Thumbprint -eq $thumb } if (-not $inUser) { $pfxBytes = [Convert]::FromBase64String($env:PFX_B64) $tmp = Join-Path $env:TEMP "cs-$([Guid]::NewGuid()).pfx" [IO.File]::WriteAllBytes($tmp, $pfxBytes) $pwd = ConvertTo-SecureString -String $env:PFX_PWD -Force -AsPlainText Import-PfxCertificate -FilePath $tmp -CertStoreLocation Cert:\CurrentUser\My -Password $pwd | Out-Null Remove-Item -Force $tmp Write-Host "Imported into CurrentUser\My." } else { Write-Host "Already in CurrentUser\My." } $found = Get-ChildItem Cert:\LocalMachine\My -ErrorAction SilentlyContinue | Where-Object { $_.Thumbprint -eq $thumb } if ($found) { Write-Host "Cert $thumb already in LocalMachine\My." return } Write-Warning "Cert $thumb missing from LocalMachine\My. Importing from CODESIGN_CERT_PFX_BASE64." $pfxBytes = [Convert]::FromBase64String($env:PFX_B64) $tmpPfx = Join-Path $env:TEMP "codesign-$([Guid]::NewGuid()).pfx" [IO.File]::WriteAllBytes($tmpPfx, $pfxBytes) $securePwd = ConvertTo-SecureString -String $env:PFX_PWD -Force -AsPlainText Import-PfxCertificate -FilePath $tmpPfx ` -CertStoreLocation Cert:\LocalMachine\My ` -Password $securePwd | Out-Null Remove-Item -Force $tmpPfx Write-Host "Cert imported." - name: Patch csproj to use real cert thumbprint shell: powershell env: THUMB: ${{ secrets.CODESIGN_THUMBPRINT }} run: | $csproj = 'src/OutlookAddin/OutlookAddin.csproj' $orig = '2399E9BF73820F3B1FC744BCBFA7F82D536E3256' $content = Get-Content $csproj -Raw $patched = $content -replace [regex]::Escape($orig), $env:THUMB if ($patched -ne $content) { Set-Content -Path $csproj -Value $patched -NoNewline; Write-Host "Patched csproj." } else { Write-Warning "Original thumbprint not present in csproj." } - name: Build Release (restore + compile) run: msbuild OutlookAddin.sln /restore /t:Build /p:Configuration=Release /p:Platform="Any CPU" /m # Code signing -- cert in LocalMachine\My (or CurrentUser\My); signtool # picks it by thumbprint, the service runs as LocalSystem so the runtime # search reaches both stores. - name: Sign assemblies shell: powershell run: | $thumb = "${{ secrets.CODESIGN_THUMBPRINT }}" & "$env:SIGNTOOL" sign /sha1 $thumb /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 ` src/OutlookAddin/bin/Release/MarcusLaw.OutlookAddin.dll ` src/OutlookAddin.Core/bin/Release/net48/MarcusLaw.OutlookAddin.Core.dll ` src/OutlookAddin.UI/bin/Release/net48/MarcusLaw.OutlookAddin.UI.dll ` src/OutlookAddinProtocolHandler/bin/Release/net48/OutlookAddinProtocolHandler.exe - name: Publish ClickOnce shell: powershell run: | $version = "${{ steps.ver.outputs.version }}" $versionUscore = "${{ steps.ver.outputs.versionUscore }}" $thumb = "${{ secrets.CODESIGN_THUMBPRINT }}" $appBin = "src/OutlookAddin/bin/Release" $publishDir = "publish" $appFilesDir = "$publishDir/Application Files/OutlookAddin_$versionUscore" New-Item -ItemType Directory -Force -Path $appFilesDir | Out-Null Copy-Item -Recurse -Force "$appBin/*" $appFilesDir # Application manifest (.dll.manifest) & "$env:MAGE" -New Application ` -ToFile "$appFilesDir/MarcusLaw.OutlookAddin.dll.manifest" ` -Name "Marcus-Law OutlookAddin" ` -Version $version ` -FromDirectory $appFilesDir ` -TrustLevel FullTrust & "$env:MAGE" -Sign "$appFilesDir/MarcusLaw.OutlookAddin.dll.manifest" ` -CertHash $thumb ` -TimeStampUri http://timestamp.digicert.com # Deployment manifest (.vsto) Copy-Item "$appFilesDir/MarcusLaw.OutlookAddin.dll.manifest" "$publishDir/OutlookAddin.vsto" & "$env:MAGE" -Update "$publishDir/OutlookAddin.vsto" ` -AppManifest "$appFilesDir/MarcusLaw.OutlookAddin.dll.manifest" ` -ProviderUrl "$env:PROVIDER_URL" ` -Publisher "Marcus-Law" ` -Install true & "$env:MAGE" -Sign "$publishDir/OutlookAddin.vsto" ` -CertHash $thumb ` -TimeStampUri http://timestamp.digicert.com # Rename payload files to .deploy (ClickOnce convention). Get-ChildItem -Recurse -File "$appFilesDir" | Where-Object { $_.Extension -notin '.manifest','.deploy' } | ForEach-Object { Rename-Item -LiteralPath $_.FullName -NewName ($_.Name + '.deploy') } - name: Stage protocol handler shell: powershell run: | $stagingDir = "publish/protocol-handler" New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null Copy-Item -Recurse -Force "src/OutlookAddinProtocolHandler/bin/Release/net48/*" $stagingDir Copy-Item -Force "tools/install-protocol.ps1" "$stagingDir/install-protocol.ps1" Copy-Item -Force "tools/uninstall-protocol.ps1" "$stagingDir/uninstall-protocol.ps1" - name: Upload publish artifact uses: actions/upload-artifact@v4 with: name: clickonce-${{ steps.ver.outputs.versionDot }} path: publish/ dockerize-and-deploy: needs: publish-clickonce runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') env: # Set this Gitea Actions secret AFTER the outlook-addin-cdn Coolify app # is created. If empty, the trigger step is skipped (image still builds). COOLIFY_UUID: ${{ secrets.COOLIFY_UUID }} steps: - name: Checkout (Dockerfile + nginx.conf) uses: actions/checkout@v4 - name: Compute version from tag id: ver run: | TAG="${GITHUB_REF##refs/tags/}" if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Tag $TAG is not in vMAJOR.MINOR.PATCH form" >&2 exit 1 fi VERSION="${TAG#v}" echo "version=$VERSION" >> $GITHUB_OUTPUT echo "tag=$TAG" >> $GITHUB_OUTPUT - name: Download publish artifact uses: actions/download-artifact@v4 with: name: clickonce-${{ steps.ver.outputs.version }} path: cdn/publish/ - name: Login to Gitea Registry run: | echo "${{ secrets.REGISTRY_PASSWORD }}" | \ docker login ${{ env.REGISTRY }} \ -u "${{ secrets.REGISTRY_USER }}" --password-stdin - name: Build and push image run: | BASE="${{ env.REGISTRY }}/${{ env.CDN_IMAGE_NAME }}" docker build \ -t "${BASE}:latest" \ -t "${BASE}:${{ steps.ver.outputs.tag }}" \ -t "${BASE}:build-${{ github.run_number }}" \ cdn/ docker push "${BASE}:latest" docker push "${BASE}:${{ steps.ver.outputs.tag }}" docker push "${BASE}:build-${{ github.run_number }}" - name: Trigger Coolify redeploy if: env.COOLIFY_UUID != '' run: | curl -sf \ "http://coolify:8080/api/v1/deploy?uuid=${{ env.COOLIFY_UUID }}&force=true" \ -H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" - name: Notify Mattermost (success) if: success() env: WEBHOOK: ${{ secrets.MATTERMOST_WEBHOOK_RELEASES }} run: | if [ -z "$WEBHOOK" ]; then exit 0; fi VERSION="${{ steps.ver.outputs.version }}" curl -sf -X POST -H 'Content-Type: application/json' "$WEBHOOK" -d "$(cat <