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: Prepare + Build (single-step with full transcript) shell: powershell env: PFX_B64: ${{ secrets.CODESIGN_CERT_PFX_BASE64 }} PFX_PWD: ${{ secrets.CODESIGN_CERT_PASSWORD }} THUMB: ${{ secrets.CODESIGN_THUMBPRINT }} run: | $ErrorActionPreference = 'Continue' Start-Transcript -Path build.console.log -IncludeInvocationHeader -Force try { # --- 1. Cert into CurrentUser\My (VSTO target needs it there) --- Write-Host "=== STEP 1: Import cert into CurrentUser\My ===" $existing = Get-ChildItem Cert:\CurrentUser\My -ErrorAction SilentlyContinue | Where-Object { $_.Thumbprint -eq $env:THUMB } if ($existing) { Write-Host "Cert $env:THUMB already in CurrentUser\My." } else { $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 -ErrorAction Stop | Out-Null Remove-Item -Force $tmp Write-Host "Imported into CurrentUser\My." } # Verify what's in the store now Write-Host "Certs in CurrentUser\My:" Get-ChildItem Cert:\CurrentUser\My | ForEach-Object { Write-Host " $($_.Thumbprint) $($_.Subject)" } # --- 2. Patch csproj --- Write-Host "=== STEP 2: Patch csproj ===" $csproj = 'src/OutlookAddin/OutlookAddin.csproj' $orig = '2399E9BF73820F3B1FC744BCBFA7F82D536E3256' $content = Get-Content $csproj -Raw if ($content -match [regex]::Escape($orig)) { $patched = $content -replace [regex]::Escape($orig), $env:THUMB Set-Content -Path $csproj -Value $patched -NoNewline Write-Host "Patched csproj: $orig -> $env:THUMB" } else { Write-Host "Thumbprint $orig already replaced (or not present)." } # Verify Write-Host "Resulting ManifestCertificateThumbprint:" (Get-Content $csproj | Select-String 'ManifestCertificateThumbprint') -join "`n" | Write-Host # --- 3. Build --- Write-Host "=== STEP 3: msbuild /restore /t:Build ===" $msbuildArgs = @( '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' ) & msbuild @msbuildArgs $code = $LASTEXITCODE Write-Host "msbuild exit code: $code" } catch { Write-Host "FATAL: $($_.Exception.Message)" Write-Host $_.ScriptStackTrace $code = 1 } finally { Stop-Transcript } if ($code -ne 0) { exit $code } - name: Push build logs to Gitea generic package registry if: always() shell: powershell env: REG_USER: ${{ secrets.REGISTRY_USER }} REG_PASS: ${{ secrets.REGISTRY_PASSWORD }} RUN_ID: ${{ github.run_id }} run: | $logs = @( @{ name = 'build.console.log'; path = 'build.console.log' }, @{ name = 'build.log'; path = 'build.log' }, @{ name = 'build-errors.log'; path = 'build-errors.log' }, @{ name = 'OutlookAddin.csproj'; path = 'src/OutlookAddin/OutlookAddin.csproj' } ) $base = "https://gitea.dev.marcus-law.co.il/api/packages/espocrm-extensions/generic/outlook-addin-build-log/run-$env:RUN_ID" $pair = "$($env:REG_USER):$($env:REG_PASS)" $auth = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($pair)) foreach ($entry in $logs) { if (Test-Path $entry.path) { Write-Host "Uploading $($entry.name)..." try { $resp = Invoke-WebRequest -Method Put ` -Uri "$base/$($entry.name)" ` -Headers @{ Authorization = $auth } ` -InFile $entry.path ` -ContentType 'text/plain' ` -UseBasicParsing ` -ErrorAction Stop Write-Host " HTTP $($resp.StatusCode)" } catch { Write-Host " upload failed: $($_.Exception.Message)" } } else { Write-Host "Skip $($entry.path) (not found)" } } # Tests temporarily disabled -- actions/upload-artifact@v4 fails on this # self-hosted runner. Add back once the artifact action is fixed or once # we move test reporting to a different mechanism. publish-clickonce: needs: build runs-on: windows-latest if: startsWith(github.ref, 'refs/tags/v') steps: - name: Checkout uses: actions/checkout@v4 - name: Publish ClickOnce (single step with transcript) shell: powershell env: THUMB: ${{ secrets.CODESIGN_THUMBPRINT }} PFX_B64: ${{ secrets.CODESIGN_CERT_PFX_BASE64 }} PFX_PWD: ${{ secrets.CODESIGN_CERT_PASSWORD }} run: | $ErrorActionPreference = 'Continue' Start-Transcript -Path publish.console.log -IncludeInvocationHeader -Force $code = 0 try { $thumb = $env:THUMB Write-Host "=== STEP 0a: locate Office-capable MSBuild ===" $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" $vsInstall = & $vswhere -products * -requires Microsoft.VisualStudio.Workload.Office -property installationPath | Select-Object -First 1 if (-not $vsInstall) { throw "No VS installation with the Office workload found." } $msbuildBin = Join-Path $vsInstall 'MSBuild\Current\Bin' $env:PATH = "$msbuildBin;$env:PATH" Write-Host " MSBuild: $msbuildBin" Write-Host "=== STEP 0: resolve mage.exe + signtool.exe ===" $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" } $env:MAGE = $mage.FullName Write-Host " MAGE: $($env:MAGE)" $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" } $env:SIGNTOOL = $signtool.FullName Write-Host " SIGNTOOL: $($env:SIGNTOOL)" Write-Host "=== STEP A: cert into stores ===" $inUser = Get-ChildItem Cert:\CurrentUser\My -EA SilentlyContinue | ? { $_.Thumbprint -eq $thumb } if (-not $inUser) { $b = [Convert]::FromBase64String($env:PFX_B64); $t = Join-Path $env:TEMP "u.pfx" [IO.File]::WriteAllBytes($t, $b) $p = ConvertTo-SecureString $env:PFX_PWD -Force -AsPlainText Import-PfxCertificate -FilePath $t -CertStoreLocation Cert:\CurrentUser\My -Password $p | Out-Null Remove-Item -Force $t } Write-Host " CurrentUser\My ok" $inMach = Get-ChildItem Cert:\LocalMachine\My -EA SilentlyContinue | ? { $_.Thumbprint -eq $thumb } if (-not $inMach) { $b = [Convert]::FromBase64String($env:PFX_B64); $t = Join-Path $env:TEMP "m.pfx" [IO.File]::WriteAllBytes($t, $b) $p = ConvertTo-SecureString $env:PFX_PWD -Force -AsPlainText Import-PfxCertificate -FilePath $t -CertStoreLocation Cert:\LocalMachine\My -Password $p | Out-Null Remove-Item -Force $t } Write-Host " LocalMachine\My ok" Write-Host "=== STEP B: patch csproj ===" $csproj = 'src/OutlookAddin/OutlookAddin.csproj' $cnt = Get-Content $csproj -Raw $new = $cnt -replace [regex]::Escape('2399E9BF73820F3B1FC744BCBFA7F82D536E3256'), $thumb if ($new -ne $cnt) { Set-Content $csproj -Value $new -NoNewline; Write-Host "patched" } else { Write-Host "already patched" } Write-Host "=== STEP C: msbuild /t:Publish (VSTO ClickOnce publish target) ===" # Resolve version inline -- act_runner doesn't propagate steps.outputs # between steps reliably, so don't depend on the "Compute version" step. $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" $versionDot = $Matches[1] $versionUscore = $version -replace '\.','_' Write-Host " version=$version versionUscore=$versionUscore" # /t:Publish is the standard VSTO publish target: it builds, signs # assemblies, generates and signs the manifests, copies everything # into the PublishDir, and renames payload files to .deploy. $publishDir = (Resolve-Path .).Path + "\publish\" $msbuildArgs = @( 'OutlookAddin.sln', '/restore', '/t:Publish', '/p:Configuration=Release', '/p:Platform="Any CPU"', ('/p:PublishDir={0}' -f $publishDir), '/p:InstallUrl=https://platform.dev.marcus-law.co.il/outlook-addin/', '/p:UpdateUrl=https://platform.dev.marcus-law.co.il/outlook-addin/', ('/p:ApplicationVersion={0}' -f $version), ('/p:MinimumRequiredVersion={0}' -f $version), '/m', '/flp:logfile=publish-msbuild.log;verbosity=detailed' ) Write-Host " msbuild $($msbuildArgs -join ' ')" & msbuild @msbuildArgs if ($LASTEXITCODE -ne 0) { throw "msbuild /t:Publish failed with $LASTEXITCODE" } # Sanity check: did the VSTO publish target produce a .vsto file? $vsto = Get-ChildItem -Recurse -Filter '*.vsto' -Path publish | Select-Object -First 1 if (-not $vsto) { throw "msbuild /t:Publish completed but no .vsto file found under publish/" } Write-Host " produced $($vsto.FullName)" # Rename to canonical OutlookAddin.vsto so the ClickOnce manifest # URL matches PROVIDER_URL (which is hardcoded to .../OutlookAddin.vsto). if ($vsto.Name -ne 'OutlookAddin.vsto') { Rename-Item -LiteralPath $vsto.FullName -NewName 'OutlookAddin.vsto' } Write-Host "=== STEP F: stage protocol handler ===" $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" Write-Host "=== publish-clickonce single step done; pack happens in Upload step ===" } catch { Write-Host "FATAL: $($_.Exception.Message)" Write-Host $_.ScriptStackTrace $code = 1 } finally { try { Stop-Transcript -ErrorAction SilentlyContinue } catch {} } # Persist exit code to a side-channel file so we can inspect even if # the transcript got closed before the value was written. "code=$code at $(Get-Date)" | Out-File -FilePath publish-finish.log -Encoding ASCII # Force explicit exit -- ignore any trailing $LASTEXITCODE noise from # native commands above. if ($code -ne 0) { exit $code } else { exit 0 } - name: Pack + upload publish.tar.gz if: success() shell: powershell env: REG_USER: ${{ secrets.REGISTRY_USER }} REG_PASS: ${{ secrets.REGISTRY_PASSWORD }} run: | $tag = "${{ github.ref_name }}" if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') { throw "tag $tag not in vMAJOR.MINOR.PATCH" } $versionDot = $Matches[1] & tar -czf publish.tar.gz publish if ($LASTEXITCODE -ne 0) { throw "tar failed with $LASTEXITCODE" } $pair = "$($env:REG_USER):$($env:REG_PASS)" $auth = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($pair)) $uri = "https://gitea.dev.marcus-law.co.il/api/packages/espocrm-extensions/generic/outlook-addin-publish/$versionDot/publish.tar.gz" Invoke-WebRequest -Method Put -Uri $uri -Headers @{ Authorization = $auth } -InFile publish.tar.gz -ContentType 'application/gzip' -UseBasicParsing | Out-Null Write-Host "Uploaded $((Get-Item publish.tar.gz).Length) bytes to $uri" - name: Push publish logs to generic package if: always() shell: powershell env: REG_USER: ${{ secrets.REGISTRY_USER }} REG_PASS: ${{ secrets.REGISTRY_PASSWORD }} RUN_ID: ${{ github.run_id }} run: | $pair = "$($env:REG_USER):$($env:REG_PASS)" $auth = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($pair)) $base = "https://gitea.dev.marcus-law.co.il/api/packages/espocrm-extensions/generic/outlook-addin-publish-log/run-$env:RUN_ID" foreach ($f in @('publish.console.log','publish-msbuild.log','publish-finish.log','src/OutlookAddin/OutlookAddin.csproj')) { if (Test-Path $f) { $name = (Split-Path $f -Leaf) Write-Host "PUT $base/$name" try { Invoke-WebRequest -Method Put -Uri "$base/$name" -Headers @{ Authorization = $auth } -InFile $f -ContentType 'text/plain' -UseBasicParsing | Out-Null } catch { Write-Host " failed: $($_.Exception.Message)" } } } 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: Download publish artifact + build + push image env: REG_USER: ${{ secrets.REGISTRY_USER }} REG_PASS: ${{ secrets.REGISTRY_PASSWORD }} run: | set -e # act_runner does not propagate steps.outputs between steps, so parse # the tag inline. 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}" URL="https://gitea.dev.marcus-law.co.il/api/packages/espocrm-extensions/generic/outlook-addin-publish/${VERSION}/publish.tar.gz" curl -sfL -u "$REG_USER:$REG_PASS" "$URL" -o publish.tar.gz mkdir -p cdn tar -xzf publish.tar.gz -C cdn ls -la cdn/publish/ echo "$REG_PASS" | docker login ${{ env.REGISTRY }} -u "$REG_USER" --password-stdin BASE="${{ env.REGISTRY }}/${{ env.CDN_IMAGE_NAME }}" docker build \ -t "${BASE}:latest" \ -t "${BASE}:${TAG}" \ -t "${BASE}:build-${{ github.run_number }}" \ cdn/ docker push "${BASE}:latest" docker push "${BASE}:${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 TAG="${GITHUB_REF##refs/tags/}" VERSION="${TAG#v}" curl -sf -X POST -H 'Content-Type: application/json' "$WEBHOOK" -d "$(cat <