chore: gitignore AI config paths #41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build and Publish | ||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| version: | ||
| description: 'Version tag (e.g. v1.0.0)' | ||
| required: true | ||
| type: string | ||
| permissions: | ||
| contents: write | ||
| id-token: write | ||
| attestations: write | ||
| jobs: | ||
| build: | ||
| runs-on: windows-latest | ||
| env: | ||
| VT_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }} | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| fetch-depth: 0 | ||
| - name: Setup .NET SDK | ||
| uses: actions/setup-dotnet@v4 | ||
| with: | ||
| dotnet-version: '10.0.x' | ||
| - name: Set SOURCE_DATE_EPOCH from git commit | ||
| shell: pwsh | ||
| run: | | ||
| $epoch = git log -1 --format=%ct | ||
| echo "SOURCE_DATE_EPOCH=$epoch" >> $env:GITHUB_ENV | ||
| - name: Build and run tests | ||
| run: dotnet test "RED.Tests/RED.Tests.csproj" -c Release --logger "console;verbosity=normal" | ||
| - name: Publish self-contained single-file (x64) | ||
| run: dotnet publish "RED/RED+.csproj" -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish-x64 | ||
| - name: Publish self-contained single-file (arm64) | ||
| run: dotnet publish "RED/RED+.csproj" -c Release -r win-arm64 --self-contained true -p:PublishSingleFile=true -o publish-arm64 | ||
| - name: Safety smoke on published artifact (x64) | ||
| shell: pwsh | ||
| run: | | ||
| $ErrorActionPreference = 'Stop' | ||
| $exe = Join-Path $PWD 'publish-x64\RED+.exe' | ||
| if (!(Test-Path $exe)) { throw "Missing published RED+.exe at $exe" } | ||
| $root = Join-Path $env:RUNNER_TEMP ("redpp-rel-" + [guid]::NewGuid().ToString("N")) | ||
| $junctionTarget = $root + '-junction-target' | ||
| New-Item -ItemType Directory -Path $root | Out-Null | ||
| try { | ||
| $emptyDir = Join-Path $root 'empty-child' | ||
| $nonEmpty = Join-Path $root 'non-empty' | ||
| $junction = Join-Path $root 'junction' | ||
| $deny = Join-Path $root 'deny-access' | ||
| New-Item -ItemType Directory -Path $emptyDir, $nonEmpty, $junctionTarget, $deny | Out-Null | ||
| Set-Content -Path (Join-Path $nonEmpty 'keep.txt') -Value 'content' -NoNewline | ||
| New-Item -ItemType File -Path (Join-Path $root 'empty-file.txt') | Out-Null | ||
| New-Item -ItemType Junction -Path $junction -Target $junctionTarget | Out-Null | ||
| $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name | ||
| icacls "$deny" /deny ($currentUser + ':(OI)(CI)(R)') | Out-Null | ||
| try { | ||
| & $exe -silent -path $root -emptyfiles -mode direct | Tee-Object -Variable output | ||
| if ($LASTEXITCODE -ne 0) { throw "RED++ exited with $LASTEXITCODE`n$output" } | ||
| } finally { | ||
| icacls "$deny" /remove:d "$currentUser" | Out-Null | ||
| } | ||
| if (($output -join "`n") -notmatch 'Found 1 empty directories') { throw "Unexpected found-directory count" } | ||
| if (!(Test-Path $root)) { throw 'Root deleted despite AutoProtectRoot' } | ||
| if (Test-Path $emptyDir) { throw 'Empty child not deleted' } | ||
| if (!(Test-Path (Join-Path $nonEmpty 'keep.txt'))) { throw 'Non-empty payload deleted' } | ||
| if (!(Test-Path $junction)) { throw 'Junction deleted' } | ||
| if (!(Test-Path $junctionTarget)) { throw 'Junction target deleted' } | ||
| if (!(Test-Path $deny)) { throw 'Deny-ACL dir deleted' } | ||
| if (Test-Path (Join-Path $root 'empty-file.txt')) { throw 'Empty file not deleted' } | ||
| # Undo: restore the deleted empty directory and empty file | ||
| & $exe -undo | Tee-Object -Variable undoOutput | ||
| if ($LASTEXITCODE -ne 0) { throw "RED++ -undo exited with $LASTEXITCODE`n$undoOutput" } | ||
| if (!(Test-Path $emptyDir)) { throw 'Undo did not recreate deleted empty directory' } | ||
| if (!(Test-Path (Join-Path $root 'empty-file.txt'))) { throw 'Undo did not recreate deleted empty file' } | ||
| & $exe -version | Tee-Object -Variable ver | ||
| if (($ver -join "`n") -notmatch 'RED\+\+\s+\d+\.\d+\.\d+') { throw "Unexpected -version output: $ver" } | ||
| } | ||
| finally { | ||
| Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue | ||
| Remove-Item -LiteralPath $junctionTarget -Recurse -Force -ErrorAction SilentlyContinue | ||
| } | ||
| - name: Create release archives | ||
| shell: pwsh | ||
| run: | | ||
| # x64 archive | ||
| New-Item -ItemType Directory -Path release-x64 | Out-Null | ||
| Copy-Item publish-x64\RED+.exe release-x64\ | ||
| Copy-Item LICENSE release-x64\ | ||
| xcopy help release-x64\help\ /E /I /Q | ||
| xcopy language release-x64\language\ /E /I /Q | ||
| Compress-Archive -Path release-x64\* -DestinationPath "RED++_${{ inputs.version }}.zip" | ||
| # arm64 archive | ||
| New-Item -ItemType Directory -Path release-arm64 | Out-Null | ||
| Copy-Item publish-arm64\RED+.exe release-arm64\ | ||
| Copy-Item LICENSE release-arm64\ | ||
| xcopy help release-arm64\help\ /E /I /Q | ||
| xcopy language release-arm64\language\ /E /I /Q | ||
| Compress-Archive -Path release-arm64\* -DestinationPath "RED++_${{ inputs.version }}_arm64.zip" | ||
| - name: Generate SBOM (CycloneDX) | ||
| shell: pwsh | ||
| run: | | ||
| dotnet tool install --global CycloneDX | ||
| $env:PATH += ";$env:USERPROFILE\.dotnet\tools" | ||
| dotnet-CycloneDX "RED/RED+.csproj" -o sbom -F Json -fn bom.json | ||
| Copy-Item sbom\bom.json bom.json -Force | ||
| - name: Generate checksums | ||
| shell: pwsh | ||
| run: | | ||
| $zipX64 = "RED++_${{ inputs.version }}.zip" | ||
| $zipArm = "RED++_${{ inputs.version }}_arm64.zip" | ||
| $lines = @( | ||
| "{0} {1}" -f (Get-FileHash $zipX64 -Algorithm SHA256).Hash.ToLower(), $zipX64 | ||
| "{0} {1}" -f (Get-FileHash "release-x64\RED+.exe" -Algorithm SHA256).Hash.ToLower(), "RED+.exe (x64)" | ||
| "{0} {1}" -f (Get-FileHash $zipArm -Algorithm SHA256).Hash.ToLower(), $zipArm | ||
| "{0} {1}" -f (Get-FileHash "release-arm64\RED+.exe" -Algorithm SHA256).Hash.ToLower(), "RED+.exe (arm64)" | ||
| "{0} {1}" -f (Get-FileHash "bom.json" -Algorithm SHA256).Hash.ToLower(), "bom.json" | ||
| ) | ||
| Set-Content -Path SHA256SUMS -Value $lines -Encoding ascii | ||
| - name: Generate winget manifest | ||
| shell: pwsh | ||
| run: | | ||
| $version = "${{ inputs.version }}".TrimStart("v") | ||
| $tag = "${{ inputs.version }}" | ||
| $zipX64 = "RED++_${{ inputs.version }}.zip" | ||
| $zipArm = "RED++_${{ inputs.version }}_arm64.zip" | ||
| $hashX64 = (Get-FileHash $zipX64 -Algorithm SHA256).Hash.ToUpperInvariant() | ||
| $hashArm = (Get-FileHash $zipArm -Algorithm SHA256).Hash.ToUpperInvariant() | ||
| $manifest = "packaging\winget\SysAdminDoc.REDplusplus.yaml" | ||
| $content = Get-Content -Path $manifest -Raw | ||
| $content = $content -replace '(?m)^PackageVersion:.*$', "PackageVersion: $version" | ||
| $content = $content -replace '(?m)^( - Architecture: x64\s*\n InstallerUrl:).*$', "`$1 https://github.com/SysAdminDoc/REDplusplus/releases/download/$tag/$zipX64" | ||
| $content = $content -replace '(?m)^( InstallerSha256:).*$(?=[\s\S]*?Architecture: x64)', "`$1 $hashX64" | ||
| $content = $content -replace '(?m)^( - Architecture: arm64\s*\n InstallerUrl:).*$', "`$1 https://github.com/SysAdminDoc/REDplusplus/releases/download/$tag/$zipArm" | ||
| # Simpler approach: rewrite the entire Installers block | ||
| $newInstallers = @" | ||
| Installers: | ||
| - Architecture: x64 | ||
| InstallerUrl: https://github.com/SysAdminDoc/REDplusplus/releases/download/$tag/$zipX64 | ||
| InstallerSha256: $hashX64 | ||
| NestedInstallerType: portable | ||
| NestedInstallerFiles: | ||
| - RelativeFilePath: RED+.exe | ||
| PortableCommandAlias: red++ | ||
| - Architecture: arm64 | ||
| InstallerUrl: https://github.com/SysAdminDoc/REDplusplus/releases/download/$tag/$zipArm | ||
| InstallerSha256: $hashArm | ||
| NestedInstallerType: portable | ||
| NestedInstallerFiles: | ||
| - RelativeFilePath: RED+.exe | ||
| PortableCommandAlias: red++ | ||
| "@ | ||
| $content = $content -replace '(?ms)^Installers:.*?(?=^ManifestType:)', $newInstallers | ||
| $content = $content -replace '(?m)^PackageVersion:.*$', "PackageVersion: $version" | ||
| Set-Content -Path $manifest -Value $content -Encoding utf8 | ||
| if (Get-Command winget -ErrorAction SilentlyContinue) { | ||
| winget validate $manifest | ||
| } | ||
| - name: Attest build provenance | ||
| uses: actions/attest@v4 | ||
| with: | ||
| subject-path: | | ||
| RED++_${{ inputs.version }}.zip | ||
| RED++_${{ inputs.version }}_arm64.zip | ||
| release-x64/RED+.exe | ||
| release-arm64/RED+.exe | ||
| bom.json | ||
| - name: Create GitHub Release | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| run: | | ||
| gh release create "${{ inputs.version }}" "RED++_${{ inputs.version }}.zip" "RED++_${{ inputs.version }}_arm64.zip" "release-x64\RED+.exe" SHA256SUMS bom.json "packaging\winget\SysAdminDoc.REDplusplus.yaml" --title "RED++ ${{ inputs.version }}" --generate-notes | ||
| - name: VirusTotal scan | ||
| id: virustotal | ||
| if: env.VT_KEY != '' | ||
| uses: crazy-max/ghaction-virustotal@v5 | ||
| with: | ||
| vt_api_key: ${{ secrets.VIRUSTOTAL_API_KEY }} | ||
| files: | | ||
| RED++_${{ inputs.version }}.zip | ||
| - name: Append VirusTotal link to release notes | ||
| if: env.VT_KEY != '' && steps.virustotal.outputs.analysis != '' | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| ANALYSIS: ${{ steps.virustotal.outputs.analysis }} | ||
| shell: pwsh | ||
| run: | | ||
| $url = ($env:ANALYSIS -split ',' | Select-Object -First 1) -replace '^[^=]+=', '' | ||
| gh release edit "${{ inputs.version }}" --notes-append "`n**VirusTotal:** $url" | ||