From 9b86db69525665c39c75eb22b4ff2574f73c0daf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:52:43 +0000 Subject: [PATCH] build(deps): bump actions/setup-java from 5.7.0 to 6.0.0 Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5.7.0 to 6.0.0. - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/b6effb05e454b25005698d916606bdc6ffcbf961...dd06d9cba3e5552c54d9f8ea23572deb30010f7c) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 1146 ++++++++++++++++----------------- 1 file changed, 573 insertions(+), 573 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a55e216..5a2a502 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,573 +1,573 @@ -name: Release - -on: - push: - tags: - - 'v*' - -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - build: - name: Build and test unsigned candidate - runs-on: windows-2025 - timeout-minutes: 25 - outputs: - commit_sha: ${{ steps.policy.outputs.commit_sha }} - is_prerelease: ${{ steps.policy.outputs.is_prerelease }} - version: ${{ steps.policy.outputs.version }} - - steps: - - name: Check out the release ref - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 - with: - fetch-depth: 0 - - - name: Enforce release tag policy - id: policy - shell: pwsh - env: - RELEASE_TAG: ${{ github.ref_name }} - run: | - $ErrorActionPreference = 'Stop' - Set-StrictMode -Version Latest - - $tag = $env:RELEASE_TAG - $pattern = '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|(?=[0-9A-Za-z-]*[A-Za-z-])[0-9A-Za-z][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|(?=[0-9A-Za-z-]*[A-Za-z-])[0-9A-Za-z][0-9A-Za-z-]*))*))?$' - if (-not [regex]::IsMatch($tag, $pattern, [Text.RegularExpressions.RegexOptions]::CultureInvariant)) { - throw "Release tag '$tag' is not policy-compatible SemVer (vMAJOR.MINOR.PATCH[-PRERELEASE]); prerelease identifiers must begin with an alphanumeric character and build metadata is not accepted." - } - - $tagObjectType = (git cat-file -t "refs/tags/$tag").Trim() - if ($LASTEXITCODE -ne 0 -or $tagObjectType -ne 'tag') { - throw "Release tag '$tag' must be an annotated tag." - } - - $commit = (git rev-list -n 1 "refs/tags/$tag").Trim() - if ($LASTEXITCODE -ne 0 -or $commit -notmatch '^[0-9a-f]{40}$') { - throw "Could not resolve release tag '$tag' to one commit." - } - - git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main' - if ($LASTEXITCODE -ne 0) { - throw 'Could not fetch origin/main for the release ancestry check.' - } - git merge-base --is-ancestor $commit origin/main - if ($LASTEXITCODE -ne 0) { - throw "Release commit '$commit' is not reachable from origin/main." - } - - $versionLines = @(dotnet msbuild src/PortCVE/PortCVE.csproj -nologo -getProperty:Version) - if ($LASTEXITCODE -ne 0) { - throw 'Could not read the PortCVE project version.' - } - $projectVersion = ($versionLines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 1).Trim() - $tagVersion = $tag.Substring(1) - if (-not [StringComparer]::Ordinal.Equals($projectVersion, $tagVersion)) { - throw "Tag version '$tagVersion' does not exactly match project version '$projectVersion'." - } - - "commit_sha=$commit" >> $env:GITHUB_OUTPUT - "is_prerelease=$($tagVersion.Contains('-').ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT - "version=$tagVersion" >> $env:GITHUB_OUTPUT - - - name: Set up .NET - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: 10.0.101 - - - name: Restore locked dependencies - run: dotnet restore PortCVE.sln --locked-mode - - - name: Verify formatting - run: dotnet format PortCVE.sln --verify-no-changes --no-restore - - - name: Build - run: dotnet build PortCVE.sln -c Release --no-restore - - - name: Test - run: dotnet test PortCVE.sln -c Release --no-build --logger "trx;LogFileName=release-tests.trx" --results-directory TestResults - - - name: Test installer lifecycle under Windows PowerShell 5.1 - shell: powershell - run: .\scripts\tests\Test-Installer.ps1 - - - name: Publish unsigned candidate - run: dotnet publish src/PortCVE/PortCVE.csproj -c Release -r win-x64 --self-contained true --no-build --no-restore -o artifacts/unsigned - - - name: Verify unsigned candidate boundary and smoke test - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - Set-StrictMode -Version Latest - - $files = @(Get-ChildItem -LiteralPath artifacts/unsigned -Recurse -File) - if ($files.Count -ne 1 -or -not [StringComparer]::Ordinal.Equals($files[0].Name, 'portcve.exe')) { - throw "Unsigned publish output must contain only portcve.exe; found: $($files.FullName -join ', ')." - } - - $signature = Get-AuthenticodeSignature -LiteralPath $files[0].FullName - if ($signature.Status -ne [Management.Automation.SignatureStatus]::NotSigned -or $null -ne $signature.SignerCertificate) { - throw "Build output unexpectedly arrived signed (status: $($signature.Status)). Signing is allowed only in the protected signing job." - } - - & $files[0].FullName --version - if ($LASTEXITCODE -ne 0) { throw 'Unsigned --version smoke test failed.' } - $snapshot = (& $files[0].FullName snapshot --no-firewall 2>$null | ConvertFrom-Json) - if ($LASTEXITCODE -ne 0 -or $snapshot.schema_version -ne 1) { - throw 'Unsigned snapshot smoke test failed or returned an unexpected schema.' - } - - - name: Upload unsigned candidate for protected signing - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 - with: - name: portcve-unsigned-${{ github.run_id }}-${{ github.run_attempt }} - path: artifacts/unsigned/portcve.exe - if-no-files-found: error - compression-level: 0 - retention-days: 1 - - sign: - name: Sign and verify candidate - needs: build - runs-on: windows-2025 - timeout-minutes: 30 - environment: release-signing - permissions: - contents: read - env: - EXPECTED_SIGNER_SUBJECT: ${{ vars.EXPECTED_SIGNER_SUBJECT }} - - steps: - - name: Check out the verified release commit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 - with: - ref: ${{ needs.build.outputs.commit_sha }} - fetch-depth: 1 - - - name: Download unsigned candidate - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0 - with: - name: portcve-unsigned-${{ github.run_id }}-${{ github.run_attempt }} - path: artifacts/unsigned - - - name: Fail closed on missing signing configuration - shell: pwsh - env: - ES_USERNAME: ${{ secrets.ES_USERNAME }} - ES_PASSWORD: ${{ secrets.ES_PASSWORD }} - CREDENTIAL_ID: ${{ secrets.CREDENTIAL_ID }} - ES_TOTP_SECRET: ${{ secrets.ES_TOTP_SECRET }} - run: | - $ErrorActionPreference = 'Stop' - Set-StrictMode -Version Latest - - foreach ($name in @('ES_USERNAME', 'ES_PASSWORD', 'CREDENTIAL_ID', 'ES_TOTP_SECRET', 'EXPECTED_SIGNER_SUBJECT')) { - $value = [Environment]::GetEnvironmentVariable($name) - if ([string]::IsNullOrWhiteSpace($value)) { - throw "Required protected signing value '$name' is missing." - } - } - if ($env:EXPECTED_SIGNER_SUBJECT -ne $env:EXPECTED_SIGNER_SUBJECT.Trim()) { - throw 'EXPECTED_SIGNER_SUBJECT must not contain leading or trailing whitespace.' - } - if ($env:EXPECTED_SIGNER_SUBJECT.Contains("`r") -or $env:EXPECTED_SIGNER_SUBJECT.Contains("`n") -or - $env:EXPECTED_SIGNER_SUBJECT.Contains('__PORTCVE_EXPECTED_SIGNER_SUBJECT__')) { - throw 'EXPECTED_SIGNER_SUBJECT is invalid or still contains a template placeholder.' - } - - $candidate = @(Get-ChildItem -LiteralPath artifacts/unsigned -Recurse -File) - if ($candidate.Count -ne 1 -or -not [StringComparer]::Ordinal.Equals($candidate[0].Name, 'portcve.exe')) { - throw 'The signing input must be exactly one file named portcve.exe.' - } - $signature = Get-AuthenticodeSignature -LiteralPath $candidate[0].FullName - if ($signature.Status -ne [Management.Automation.SignatureStatus]::NotSigned -or $null -ne $signature.SignerCertificate) { - throw 'The signing input is not the unsigned candidate produced by the build job.' - } - - - name: Finalize UTF-8 BOM installer for signing - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - Set-StrictMode -Version Latest - - ./scripts/Finalize-ReleaseInstaller.ps1 ` - -TemplatePath scripts/install.ps1 ` - -OutputPath artifacts/unsigned-installer/install.ps1 ` - -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT - - $installerPath = (Resolve-Path -LiteralPath artifacts/unsigned-installer/install.ps1).Path - $bytes = [IO.File]::ReadAllBytes($installerPath) - if ($bytes.Length -lt 3 -or $bytes[0] -ne 0xef -or $bytes[1] -ne 0xbb -or $bytes[2] -ne 0xbf) { - throw 'Finalized installer lost its required UTF-8 BOM.' - } - $signature = Get-AuthenticodeSignature -LiteralPath $installerPath - if ($signature.Status -ne [Management.Automation.SignatureStatus]::NotSigned -or $null -ne $signature.SignerCertificate) { - throw 'Finalized installer must be unsigned before entering the protected signing action.' - } - - - name: Set up Java for SSL.com CodeSignTool - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.0.0 - with: - distribution: temurin - java-version: '21' - - - name: Install integrity-pinned SSL.com CodeSignTool - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - Set-StrictMode -Version Latest - - $archiveUri = 'https://github.com/SSLcom/CodeSignTool/releases/download/v1.3.0/CodeSignTool-v1.3.0-windows.zip' - $expectedHash = 'e22094505decbe622afe5b0c27abc618ed2ba179bd94f3450490352399d5ef2a' - $archive = Join-Path $env:RUNNER_TEMP 'CodeSignTool-v1.3.0-windows.zip' - $toolRoot = Join-Path $env:RUNNER_TEMP 'portcve-codesigntool-v1.3.0' - - Invoke-WebRequest -UseBasicParsing -Uri $archiveUri -OutFile $archive - $actualHash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() - if (-not [StringComparer]::Ordinal.Equals($actualHash, $expectedHash)) { - throw "SSL.com CodeSignTool archive hash mismatch: $actualHash." - } - - New-Item -ItemType Directory -Path $toolRoot | Out-Null - Expand-Archive -LiteralPath $archive -DestinationPath $toolRoot - foreach ($required in @('CodeSignTool.bat', 'jar/code_sign_tool-1.3.0.jar', 'conf/code_sign_tool.properties')) { - if (-not (Test-Path -LiteralPath (Join-Path $toolRoot $required) -PathType Leaf)) { - throw "SSL.com CodeSignTool archive is missing '$required'." - } - } - - "CODESIGNTOOL_PATH=$toolRoot" >> $env:GITHUB_ENV - 'JAVA_VERSION=21' >> $env:GITHUB_ENV - - - name: Sign portcve.exe with SSL.com eSigner - uses: SSLcom/esigner-codesign@b7f8ff36fc0de8690fbbab8e5b4421d29802f747 # reviewed 2025-06-23 commit; action 1.3.2 - with: - command: sign - username: ${{ secrets.ES_USERNAME }} - password: ${{ secrets.ES_PASSWORD }} - credential_id: ${{ secrets.CREDENTIAL_ID }} - totp_secret: ${{ secrets.ES_TOTP_SECRET }} - program_name: PortCVE - file_path: ${{ github.workspace }}\artifacts\unsigned\portcve.exe - output_path: ${{ github.workspace }}\artifacts\signed - malware_block: true - override: false - clean_logs: true - environment_name: PROD - jvm_max_memory: 1024M - signing_method: v2 - - - name: Sign finalized install.ps1 with SSL.com eSigner - uses: SSLcom/esigner-codesign@b7f8ff36fc0de8690fbbab8e5b4421d29802f747 # reviewed 2025-06-23 commit; action 1.3.2 - with: - command: sign - username: ${{ secrets.ES_USERNAME }} - password: ${{ secrets.ES_PASSWORD }} - credential_id: ${{ secrets.CREDENTIAL_ID }} - totp_secret: ${{ secrets.ES_TOTP_SECRET }} - program_name: PortCVE Installer - file_path: ${{ github.workspace }}\artifacts\unsigned-installer\install.ps1 - output_path: ${{ github.workspace }}\artifacts\signed - malware_block: true - override: false - clean_logs: true - environment_name: PROD - jvm_max_memory: 1024M - signing_method: v2 - - - name: Independently verify signed output - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - Set-StrictMode -Version Latest - - $files = @(Get-ChildItem -LiteralPath artifacts/signed -Recurse -File) - $expectedNames = @('install.ps1', 'portcve.exe') - if ($files.Count -ne $expectedNames.Count -or (Compare-Object @($files.Name | Sort-Object) @($expectedNames | Sort-Object))) { - throw "Signing output must contain only install.ps1 and portcve.exe; found: $($files.FullName -join ', ')." - } - ./scripts/Verify-ReleaseSignature.ps1 -Path artifacts/signed/portcve.exe -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT - ./scripts/Verify-ReleaseSignature.ps1 -Path artifacts/signed/install.ps1 -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT - - $installerBytes = [IO.File]::ReadAllBytes((Resolve-Path -LiteralPath artifacts/signed/install.ps1)) - if ($installerBytes.Length -lt 3 -or $installerBytes[0] -ne 0xef -or $installerBytes[1] -ne 0xbb -or $installerBytes[2] -ne 0xbf) { - throw 'Signed installer lost its required UTF-8 BOM.' - } - $tokens = $null - $parseErrors = $null - $installerAst = [Management.Automation.Language.Parser]::ParseFile( - (Resolve-Path -LiteralPath artifacts/signed/install.ps1).Path, - [ref]$tokens, - [ref]$parseErrors - ) - $subjectAssignments = @($installerAst.FindAll({ - param($node) - $node -is [Management.Automation.Language.AssignmentStatementAst] -and - [StringComparer]::Ordinal.Equals($node.Left.Extent.Text, '$script:ExpectedSignerSubject') - }, $true)) - if ($parseErrors.Count -ne 0 -or $subjectAssignments.Count -ne 1 -or - $subjectAssignments[0].Right -isnot [Management.Automation.Language.CommandExpressionAst] -or - $subjectAssignments[0].Right.Expression -isnot [Management.Automation.Language.StringConstantExpressionAst] -or - -not [StringComparer]::Ordinal.Equals([string]$subjectAssignments[0].Right.Expression.Value, $env:EXPECTED_SIGNER_SUBJECT)) { - throw 'Signed installer does not contain the exact release signer subject.' - } - - - name: Smoke test signed executable - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $executable = (Resolve-Path -LiteralPath artifacts/signed/portcve.exe).Path - & $executable --version - if ($LASTEXITCODE -ne 0) { throw 'Signed --version smoke test failed.' } - $snapshot = (& $executable snapshot --no-firewall 2>$null | ConvertFrom-Json) - if ($LASTEXITCODE -ne 0 -or $snapshot.schema_version -ne 1) { - throw 'Signed snapshot smoke test failed or returned an unexpected schema.' - } - - - name: Write signing metadata - shell: pwsh - env: - RELEASE_COMMIT: ${{ needs.build.outputs.commit_sha }} - RELEASE_TAG: ${{ github.ref_name }} - run: | - ./scripts/Write-SigningMetadata.ps1 ` - -Path artifacts/signed/portcve.exe ` - -OutputPath artifacts/signed/SIGNING-METADATA.json ` - -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT ` - -Repository $env:GITHUB_REPOSITORY ` - -CommitSha $env:RELEASE_COMMIT ` - -Tag $env:RELEASE_TAG ` - -WorkflowRunId $env:GITHUB_RUN_ID ` - -WorkflowRunAttempt $env:GITHUB_RUN_ATTEMPT ` - -SigningActionCommit 'b7f8ff36fc0de8690fbbab8e5b4421d29802f747' ` - -CodeSignToolVersion '1.3.0' ` - -CodeSignToolArchiveSha256 'e22094505decbe622afe5b0c27abc618ed2ba179bd94f3450490352399d5ef2a' - - - name: Upload verified signed candidate - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 - with: - name: portcve-signed-${{ github.run_id }}-${{ github.run_attempt }} - path: | - artifacts/signed/portcve.exe - artifacts/signed/install.ps1 - artifacts/signed/SIGNING-METADATA.json - if-no-files-found: error - compression-level: 0 - retention-days: 7 - - package_publish: - name: Package, attest, and publish - needs: - - build - - sign - runs-on: windows-2025 - timeout-minutes: 25 - permissions: - contents: write - id-token: write - attestations: write - env: - EXPECTED_SIGNER_SUBJECT: ${{ vars.EXPECTED_SIGNER_SUBJECT }} - RELEASE_COMMIT: ${{ needs.build.outputs.commit_sha }} - RELEASE_TAG: ${{ github.ref_name }} - IS_PRERELEASE: ${{ needs.build.outputs.is_prerelease }} - - steps: - - name: Check out the verified release commit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 - with: - ref: ${{ needs.build.outputs.commit_sha }} - fetch-depth: 1 - - - name: Download verified signed candidate - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0 - with: - name: portcve-signed-${{ github.run_id }}-${{ github.run_attempt }} - path: artifacts/signed - - - name: Reverify transferred signature and metadata - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - Set-StrictMode -Version Latest - - $files = @(Get-ChildItem -LiteralPath artifacts/signed -Recurse -File) - $expectedNames = @('install.ps1', 'portcve.exe', 'SIGNING-METADATA.json') - if ($files.Count -ne $expectedNames.Count -or @($files.Name | Where-Object { $_ -notin $expectedNames }).Count -ne 0) { - throw "Signed transfer contains an unexpected file set: $($files.Name -join ', ')." - } - - ./scripts/Verify-ReleaseSignature.ps1 -Path artifacts/signed/portcve.exe -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT - ./scripts/Verify-ReleaseSignature.ps1 -Path artifacts/signed/install.ps1 -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT - - $metadata = Get-Content -LiteralPath artifacts/signed/SIGNING-METADATA.json -Raw | ConvertFrom-Json - $actualHash = (Get-FileHash -LiteralPath artifacts/signed/portcve.exe -Algorithm SHA256).Hash.ToLowerInvariant() - if ($metadata.schema_version -ne 1 -or - -not [StringComparer]::Ordinal.Equals([string]$metadata.artifact.sha256, $actualHash) -or - -not [StringComparer]::Ordinal.Equals([string]$metadata.signature.signer.subject, $env:EXPECTED_SIGNER_SUBJECT) -or - $metadata.signature.timestamp.binding_verified -ne $true -or - -not [StringComparer]::Ordinal.Equals([string]$metadata.signature.timestamp.binding_method, 'Rfc3161TimestampToken.VerifySignatureForSignerInfo') -or - [string]::IsNullOrWhiteSpace([string]$metadata.signature.timestamp.timestamp_utc) -or - -not [StringComparer]::Ordinal.Equals([string]$metadata.source.repository, $env:GITHUB_REPOSITORY) -or - -not [StringComparer]::OrdinalIgnoreCase.Equals([string]$metadata.source.commit_sha, $env:RELEASE_COMMIT) -or - -not [StringComparer]::Ordinal.Equals([string]$metadata.source.tag, $env:RELEASE_TAG)) { - throw 'Signing metadata does not bind the downloaded executable to this exact release context.' - } - - - name: Package signed release assets and checksums - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - Set-StrictMode -Version Latest - - $packageRoot = Join-Path $PWD 'artifacts/package' - $releaseRoot = Join-Path $PWD 'artifacts/release' - New-Item -ItemType Directory -Path $packageRoot, $releaseRoot | Out-Null - - Copy-Item -LiteralPath artifacts/signed/portcve.exe -Destination $packageRoot - Copy-Item -LiteralPath artifacts/signed/SIGNING-METADATA.json -Destination $packageRoot - foreach ($document in @('README.md', 'CHANGELOG.md', 'LICENSE', 'SECURITY.md')) { - Copy-Item -LiteralPath $document -Destination $packageRoot - } - Copy-Item -LiteralPath schema -Destination (Join-Path $packageRoot 'schema') -Recurse - - $zipName = "portcve-$($env:RELEASE_TAG)-win-x64.zip" - $zipPath = Join-Path $releaseRoot $zipName - Compress-Archive -Path (Join-Path $packageRoot '*') -DestinationPath $zipPath - Copy-Item -LiteralPath artifacts/signed/portcve.exe -Destination $releaseRoot - Copy-Item -LiteralPath artifacts/signed/install.ps1 -Destination $releaseRoot - Copy-Item -LiteralPath artifacts/signed/SIGNING-METADATA.json -Destination $releaseRoot - - Add-Type -AssemblyName System.IO.Compression - Add-Type -AssemblyName System.IO.Compression.FileSystem - $archive = [IO.Compression.ZipFile]::OpenRead($zipPath) - try { - $zipExecutables = @($archive.Entries | Where-Object { - [StringComparer]::Ordinal.Equals($_.FullName.Replace('\', '/'), 'portcve.exe') - }) - if ($zipExecutables.Count -ne 1) { - throw "Portable ZIP must contain exactly one root portcve.exe; found $($zipExecutables.Count)." - } - $zipStream = $zipExecutables[0].Open() - $sha256 = [Security.Cryptography.SHA256]::Create() - try { - $zipExecutableHash = ([BitConverter]::ToString($sha256.ComputeHash($zipStream))).Replace('-', '').ToLowerInvariant() - } - finally { - $sha256.Dispose() - $zipStream.Dispose() - } - } - finally { - $archive.Dispose() - } - - $signedExecutableHash = (Get-FileHash -LiteralPath artifacts/signed/portcve.exe -Algorithm SHA256).Hash.ToLowerInvariant() - $signingMetadata = Get-Content -LiteralPath artifacts/signed/SIGNING-METADATA.json -Raw | ConvertFrom-Json - if (-not [StringComparer]::Ordinal.Equals($zipExecutableHash, $signedExecutableHash) -or - -not [StringComparer]::Ordinal.Equals([string]$signingMetadata.artifact.sha256, $signedExecutableHash)) { - throw 'Portable ZIP, standalone executable, and signing metadata do not contain the exact same signed executable.' - } - - $checksumTargets = @(Get-ChildItem -LiteralPath $releaseRoot -File | Sort-Object Name) - $checksumLines = @($checksumTargets | ForEach-Object { - $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() - "$hash $($_.Name)" - }) - [IO.File]::WriteAllLines((Join-Path $releaseRoot 'SHA256SUMS.txt'), $checksumLines, [Text.Encoding]::ASCII) - - ./scripts/Verify-ReleaseSignature.ps1 -Path (Join-Path $releaseRoot 'portcve.exe') -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT - ./scripts/Verify-ReleaseSignature.ps1 -Path (Join-Path $releaseRoot 'install.ps1') -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT - - - name: Attest release asset provenance - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 - with: - subject-path: artifacts/release/* - - - name: Create verified draft release, then publish - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - run: | - $ErrorActionPreference = 'Stop' - Set-StrictMode -Version Latest - - $immutableJson = & gh api "repos/$env:GITHUB_REPOSITORY/immutable-releases" 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "Could not verify immutable-releases configuration: $($immutableJson -join [Environment]::NewLine)" - } - $immutable = ($immutableJson -join [Environment]::NewLine) | ConvertFrom-Json - if ($immutable.enabled -ne $true) { - throw 'Immutable releases must be enabled before PortCVE can publish a release.' - } - - $existing = & gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$env:RELEASE_TAG" 2>&1 - $existingExit = $LASTEXITCODE - if ($existingExit -eq 0) { - throw "Release '$env:RELEASE_TAG' already exists; refusing to overwrite it." - } - if (($existing -join [Environment]::NewLine) -notmatch '(?i)\b404\b|not found') { - throw "Could not prove that the release tag is unused: $($existing -join [Environment]::NewLine)" - } - - $assets = @(Get-ChildItem -LiteralPath artifacts/release -File | Sort-Object Name) - $expectedAssetNames = @('install.ps1', 'portcve.exe', "portcve-$($env:RELEASE_TAG)-win-x64.zip", 'SHA256SUMS.txt', 'SIGNING-METADATA.json') | Sort-Object - if ($assets.Count -ne $expectedAssetNames.Count -or (Compare-Object @($assets.Name | Sort-Object) $expectedAssetNames)) { - throw "Release asset boundary is invalid: $($assets.Name -join ', ')." - } - - $createArgs = @('release', 'create', $env:RELEASE_TAG) - $createArgs += @($assets.FullName) - $createArgs += @('--draft', '--generate-notes', '--verify-tag', '--title', "PortCVE $env:RELEASE_TAG") - & gh @createArgs - if ($LASTEXITCODE -ne 0) { throw 'Creating the draft GitHub release failed.' } - - $draftJson = & gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$env:RELEASE_TAG" - if ($LASTEXITCODE -ne 0) { throw 'Could not read back the draft release.' } - $draft = ($draftJson -join [Environment]::NewLine) | ConvertFrom-Json - if ($draft.draft -ne $true) { throw 'The release was not created as a draft.' } - - $localDigests = @{} - foreach ($asset in $assets) { - $localDigests[$asset.Name] = "sha256:$((Get-FileHash -LiteralPath $asset.FullName -Algorithm SHA256).Hash.ToLowerInvariant())" - } - - $assetsVerified = $false - for ($attempt = 1; $attempt -le 5; $attempt++) { - $remoteJson = & gh api "repos/$env:GITHUB_REPOSITORY/releases/$($draft.id)/assets?per_page=100" - if ($LASTEXITCODE -ne 0) { throw 'Could not read back draft release assets.' } - $remoteAssets = @(($remoteJson -join [Environment]::NewLine) | ConvertFrom-Json) - $assetsVerified = $remoteAssets.Count -eq $assets.Count - foreach ($remote in $remoteAssets) { - if (-not $localDigests.ContainsKey([string]$remote.name) -or - -not [StringComparer]::OrdinalIgnoreCase.Equals([string]$remote.digest, [string]$localDigests[[string]$remote.name])) { - $assetsVerified = $false - } - } - if ($assetsVerified) { break } - Start-Sleep -Seconds 2 - } - if (-not $assetsVerified) { - throw 'GitHub did not report an exact SHA-256 digest match for every draft release asset.' - } - - if ([bool]::Parse($env:IS_PRERELEASE)) { - & gh release edit $env:RELEASE_TAG --draft=false --prerelease - } else { - & gh release edit $env:RELEASE_TAG --draft=false --latest - } - if ($LASTEXITCODE -ne 0) { throw 'Publishing the verified draft release failed.' } - - $publishedJson = & gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$env:RELEASE_TAG" - if ($LASTEXITCODE -ne 0) { throw 'Could not verify the published release state.' } - $published = ($publishedJson -join [Environment]::NewLine) | ConvertFrom-Json - $expectedPrerelease = [bool]::Parse($env:IS_PRERELEASE) - if ($published.draft -ne $false -or $published.prerelease -ne $expectedPrerelease) { - throw 'Published release state does not match the verified release policy.' - } +name: Release + +on: + push: + tags: + - 'v*' + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + build: + name: Build and test unsigned candidate + runs-on: windows-2025 + timeout-minutes: 25 + outputs: + commit_sha: ${{ steps.policy.outputs.commit_sha }} + is_prerelease: ${{ steps.policy.outputs.is_prerelease }} + version: ${{ steps.policy.outputs.version }} + + steps: + - name: Check out the release ref + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 + with: + fetch-depth: 0 + + - name: Enforce release tag policy + id: policy + shell: pwsh + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + $tag = $env:RELEASE_TAG + $pattern = '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|(?=[0-9A-Za-z-]*[A-Za-z-])[0-9A-Za-z][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|(?=[0-9A-Za-z-]*[A-Za-z-])[0-9A-Za-z][0-9A-Za-z-]*))*))?$' + if (-not [regex]::IsMatch($tag, $pattern, [Text.RegularExpressions.RegexOptions]::CultureInvariant)) { + throw "Release tag '$tag' is not policy-compatible SemVer (vMAJOR.MINOR.PATCH[-PRERELEASE]); prerelease identifiers must begin with an alphanumeric character and build metadata is not accepted." + } + + $tagObjectType = (git cat-file -t "refs/tags/$tag").Trim() + if ($LASTEXITCODE -ne 0 -or $tagObjectType -ne 'tag') { + throw "Release tag '$tag' must be an annotated tag." + } + + $commit = (git rev-list -n 1 "refs/tags/$tag").Trim() + if ($LASTEXITCODE -ne 0 -or $commit -notmatch '^[0-9a-f]{40}$') { + throw "Could not resolve release tag '$tag' to one commit." + } + + git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main' + if ($LASTEXITCODE -ne 0) { + throw 'Could not fetch origin/main for the release ancestry check.' + } + git merge-base --is-ancestor $commit origin/main + if ($LASTEXITCODE -ne 0) { + throw "Release commit '$commit' is not reachable from origin/main." + } + + $versionLines = @(dotnet msbuild src/PortCVE/PortCVE.csproj -nologo -getProperty:Version) + if ($LASTEXITCODE -ne 0) { + throw 'Could not read the PortCVE project version.' + } + $projectVersion = ($versionLines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 1).Trim() + $tagVersion = $tag.Substring(1) + if (-not [StringComparer]::Ordinal.Equals($projectVersion, $tagVersion)) { + throw "Tag version '$tagVersion' does not exactly match project version '$projectVersion'." + } + + "commit_sha=$commit" >> $env:GITHUB_OUTPUT + "is_prerelease=$($tagVersion.Contains('-').ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + "version=$tagVersion" >> $env:GITHUB_OUTPUT + + - name: Set up .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.101 + + - name: Restore locked dependencies + run: dotnet restore PortCVE.sln --locked-mode + + - name: Verify formatting + run: dotnet format PortCVE.sln --verify-no-changes --no-restore + + - name: Build + run: dotnet build PortCVE.sln -c Release --no-restore + + - name: Test + run: dotnet test PortCVE.sln -c Release --no-build --logger "trx;LogFileName=release-tests.trx" --results-directory TestResults + + - name: Test installer lifecycle under Windows PowerShell 5.1 + shell: powershell + run: .\scripts\tests\Test-Installer.ps1 + + - name: Publish unsigned candidate + run: dotnet publish src/PortCVE/PortCVE.csproj -c Release -r win-x64 --self-contained true --no-build --no-restore -o artifacts/unsigned + + - name: Verify unsigned candidate boundary and smoke test + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + $files = @(Get-ChildItem -LiteralPath artifacts/unsigned -Recurse -File) + if ($files.Count -ne 1 -or -not [StringComparer]::Ordinal.Equals($files[0].Name, 'portcve.exe')) { + throw "Unsigned publish output must contain only portcve.exe; found: $($files.FullName -join ', ')." + } + + $signature = Get-AuthenticodeSignature -LiteralPath $files[0].FullName + if ($signature.Status -ne [Management.Automation.SignatureStatus]::NotSigned -or $null -ne $signature.SignerCertificate) { + throw "Build output unexpectedly arrived signed (status: $($signature.Status)). Signing is allowed only in the protected signing job." + } + + & $files[0].FullName --version + if ($LASTEXITCODE -ne 0) { throw 'Unsigned --version smoke test failed.' } + $snapshot = (& $files[0].FullName snapshot --no-firewall 2>$null | ConvertFrom-Json) + if ($LASTEXITCODE -ne 0 -or $snapshot.schema_version -ne 1) { + throw 'Unsigned snapshot smoke test failed or returned an unexpected schema.' + } + + - name: Upload unsigned candidate for protected signing + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: portcve-unsigned-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/unsigned/portcve.exe + if-no-files-found: error + compression-level: 0 + retention-days: 1 + + sign: + name: Sign and verify candidate + needs: build + runs-on: windows-2025 + timeout-minutes: 30 + environment: release-signing + permissions: + contents: read + env: + EXPECTED_SIGNER_SUBJECT: ${{ vars.EXPECTED_SIGNER_SUBJECT }} + + steps: + - name: Check out the verified release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 + with: + ref: ${{ needs.build.outputs.commit_sha }} + fetch-depth: 1 + + - name: Download unsigned candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0 + with: + name: portcve-unsigned-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/unsigned + + - name: Fail closed on missing signing configuration + shell: pwsh + env: + ES_USERNAME: ${{ secrets.ES_USERNAME }} + ES_PASSWORD: ${{ secrets.ES_PASSWORD }} + CREDENTIAL_ID: ${{ secrets.CREDENTIAL_ID }} + ES_TOTP_SECRET: ${{ secrets.ES_TOTP_SECRET }} + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + foreach ($name in @('ES_USERNAME', 'ES_PASSWORD', 'CREDENTIAL_ID', 'ES_TOTP_SECRET', 'EXPECTED_SIGNER_SUBJECT')) { + $value = [Environment]::GetEnvironmentVariable($name) + if ([string]::IsNullOrWhiteSpace($value)) { + throw "Required protected signing value '$name' is missing." + } + } + if ($env:EXPECTED_SIGNER_SUBJECT -ne $env:EXPECTED_SIGNER_SUBJECT.Trim()) { + throw 'EXPECTED_SIGNER_SUBJECT must not contain leading or trailing whitespace.' + } + if ($env:EXPECTED_SIGNER_SUBJECT.Contains("`r") -or $env:EXPECTED_SIGNER_SUBJECT.Contains("`n") -or + $env:EXPECTED_SIGNER_SUBJECT.Contains('__PORTCVE_EXPECTED_SIGNER_SUBJECT__')) { + throw 'EXPECTED_SIGNER_SUBJECT is invalid or still contains a template placeholder.' + } + + $candidate = @(Get-ChildItem -LiteralPath artifacts/unsigned -Recurse -File) + if ($candidate.Count -ne 1 -or -not [StringComparer]::Ordinal.Equals($candidate[0].Name, 'portcve.exe')) { + throw 'The signing input must be exactly one file named portcve.exe.' + } + $signature = Get-AuthenticodeSignature -LiteralPath $candidate[0].FullName + if ($signature.Status -ne [Management.Automation.SignatureStatus]::NotSigned -or $null -ne $signature.SignerCertificate) { + throw 'The signing input is not the unsigned candidate produced by the build job.' + } + + - name: Finalize UTF-8 BOM installer for signing + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + ./scripts/Finalize-ReleaseInstaller.ps1 ` + -TemplatePath scripts/install.ps1 ` + -OutputPath artifacts/unsigned-installer/install.ps1 ` + -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT + + $installerPath = (Resolve-Path -LiteralPath artifacts/unsigned-installer/install.ps1).Path + $bytes = [IO.File]::ReadAllBytes($installerPath) + if ($bytes.Length -lt 3 -or $bytes[0] -ne 0xef -or $bytes[1] -ne 0xbb -or $bytes[2] -ne 0xbf) { + throw 'Finalized installer lost its required UTF-8 BOM.' + } + $signature = Get-AuthenticodeSignature -LiteralPath $installerPath + if ($signature.Status -ne [Management.Automation.SignatureStatus]::NotSigned -or $null -ne $signature.SignerCertificate) { + throw 'Finalized installer must be unsigned before entering the protected signing action.' + } + + - name: Set up Java for SSL.com CodeSignTool + uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v5.0.0 + with: + distribution: temurin + java-version: '21' + + - name: Install integrity-pinned SSL.com CodeSignTool + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + $archiveUri = 'https://github.com/SSLcom/CodeSignTool/releases/download/v1.3.0/CodeSignTool-v1.3.0-windows.zip' + $expectedHash = 'e22094505decbe622afe5b0c27abc618ed2ba179bd94f3450490352399d5ef2a' + $archive = Join-Path $env:RUNNER_TEMP 'CodeSignTool-v1.3.0-windows.zip' + $toolRoot = Join-Path $env:RUNNER_TEMP 'portcve-codesigntool-v1.3.0' + + Invoke-WebRequest -UseBasicParsing -Uri $archiveUri -OutFile $archive + $actualHash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() + if (-not [StringComparer]::Ordinal.Equals($actualHash, $expectedHash)) { + throw "SSL.com CodeSignTool archive hash mismatch: $actualHash." + } + + New-Item -ItemType Directory -Path $toolRoot | Out-Null + Expand-Archive -LiteralPath $archive -DestinationPath $toolRoot + foreach ($required in @('CodeSignTool.bat', 'jar/code_sign_tool-1.3.0.jar', 'conf/code_sign_tool.properties')) { + if (-not (Test-Path -LiteralPath (Join-Path $toolRoot $required) -PathType Leaf)) { + throw "SSL.com CodeSignTool archive is missing '$required'." + } + } + + "CODESIGNTOOL_PATH=$toolRoot" >> $env:GITHUB_ENV + 'JAVA_VERSION=21' >> $env:GITHUB_ENV + + - name: Sign portcve.exe with SSL.com eSigner + uses: SSLcom/esigner-codesign@b7f8ff36fc0de8690fbbab8e5b4421d29802f747 # reviewed 2025-06-23 commit; action 1.3.2 + with: + command: sign + username: ${{ secrets.ES_USERNAME }} + password: ${{ secrets.ES_PASSWORD }} + credential_id: ${{ secrets.CREDENTIAL_ID }} + totp_secret: ${{ secrets.ES_TOTP_SECRET }} + program_name: PortCVE + file_path: ${{ github.workspace }}\artifacts\unsigned\portcve.exe + output_path: ${{ github.workspace }}\artifacts\signed + malware_block: true + override: false + clean_logs: true + environment_name: PROD + jvm_max_memory: 1024M + signing_method: v2 + + - name: Sign finalized install.ps1 with SSL.com eSigner + uses: SSLcom/esigner-codesign@b7f8ff36fc0de8690fbbab8e5b4421d29802f747 # reviewed 2025-06-23 commit; action 1.3.2 + with: + command: sign + username: ${{ secrets.ES_USERNAME }} + password: ${{ secrets.ES_PASSWORD }} + credential_id: ${{ secrets.CREDENTIAL_ID }} + totp_secret: ${{ secrets.ES_TOTP_SECRET }} + program_name: PortCVE Installer + file_path: ${{ github.workspace }}\artifacts\unsigned-installer\install.ps1 + output_path: ${{ github.workspace }}\artifacts\signed + malware_block: true + override: false + clean_logs: true + environment_name: PROD + jvm_max_memory: 1024M + signing_method: v2 + + - name: Independently verify signed output + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + $files = @(Get-ChildItem -LiteralPath artifacts/signed -Recurse -File) + $expectedNames = @('install.ps1', 'portcve.exe') + if ($files.Count -ne $expectedNames.Count -or (Compare-Object @($files.Name | Sort-Object) @($expectedNames | Sort-Object))) { + throw "Signing output must contain only install.ps1 and portcve.exe; found: $($files.FullName -join ', ')." + } + ./scripts/Verify-ReleaseSignature.ps1 -Path artifacts/signed/portcve.exe -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT + ./scripts/Verify-ReleaseSignature.ps1 -Path artifacts/signed/install.ps1 -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT + + $installerBytes = [IO.File]::ReadAllBytes((Resolve-Path -LiteralPath artifacts/signed/install.ps1)) + if ($installerBytes.Length -lt 3 -or $installerBytes[0] -ne 0xef -or $installerBytes[1] -ne 0xbb -or $installerBytes[2] -ne 0xbf) { + throw 'Signed installer lost its required UTF-8 BOM.' + } + $tokens = $null + $parseErrors = $null + $installerAst = [Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path -LiteralPath artifacts/signed/install.ps1).Path, + [ref]$tokens, + [ref]$parseErrors + ) + $subjectAssignments = @($installerAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + [StringComparer]::Ordinal.Equals($node.Left.Extent.Text, '$script:ExpectedSignerSubject') + }, $true)) + if ($parseErrors.Count -ne 0 -or $subjectAssignments.Count -ne 1 -or + $subjectAssignments[0].Right -isnot [Management.Automation.Language.CommandExpressionAst] -or + $subjectAssignments[0].Right.Expression -isnot [Management.Automation.Language.StringConstantExpressionAst] -or + -not [StringComparer]::Ordinal.Equals([string]$subjectAssignments[0].Right.Expression.Value, $env:EXPECTED_SIGNER_SUBJECT)) { + throw 'Signed installer does not contain the exact release signer subject.' + } + + - name: Smoke test signed executable + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $executable = (Resolve-Path -LiteralPath artifacts/signed/portcve.exe).Path + & $executable --version + if ($LASTEXITCODE -ne 0) { throw 'Signed --version smoke test failed.' } + $snapshot = (& $executable snapshot --no-firewall 2>$null | ConvertFrom-Json) + if ($LASTEXITCODE -ne 0 -or $snapshot.schema_version -ne 1) { + throw 'Signed snapshot smoke test failed or returned an unexpected schema.' + } + + - name: Write signing metadata + shell: pwsh + env: + RELEASE_COMMIT: ${{ needs.build.outputs.commit_sha }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + ./scripts/Write-SigningMetadata.ps1 ` + -Path artifacts/signed/portcve.exe ` + -OutputPath artifacts/signed/SIGNING-METADATA.json ` + -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT ` + -Repository $env:GITHUB_REPOSITORY ` + -CommitSha $env:RELEASE_COMMIT ` + -Tag $env:RELEASE_TAG ` + -WorkflowRunId $env:GITHUB_RUN_ID ` + -WorkflowRunAttempt $env:GITHUB_RUN_ATTEMPT ` + -SigningActionCommit 'b7f8ff36fc0de8690fbbab8e5b4421d29802f747' ` + -CodeSignToolVersion '1.3.0' ` + -CodeSignToolArchiveSha256 'e22094505decbe622afe5b0c27abc618ed2ba179bd94f3450490352399d5ef2a' + + - name: Upload verified signed candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: portcve-signed-${{ github.run_id }}-${{ github.run_attempt }} + path: | + artifacts/signed/portcve.exe + artifacts/signed/install.ps1 + artifacts/signed/SIGNING-METADATA.json + if-no-files-found: error + compression-level: 0 + retention-days: 7 + + package_publish: + name: Package, attest, and publish + needs: + - build + - sign + runs-on: windows-2025 + timeout-minutes: 25 + permissions: + contents: write + id-token: write + attestations: write + env: + EXPECTED_SIGNER_SUBJECT: ${{ vars.EXPECTED_SIGNER_SUBJECT }} + RELEASE_COMMIT: ${{ needs.build.outputs.commit_sha }} + RELEASE_TAG: ${{ github.ref_name }} + IS_PRERELEASE: ${{ needs.build.outputs.is_prerelease }} + + steps: + - name: Check out the verified release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0 + with: + ref: ${{ needs.build.outputs.commit_sha }} + fetch-depth: 1 + + - name: Download verified signed candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0 + with: + name: portcve-signed-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/signed + + - name: Reverify transferred signature and metadata + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + $files = @(Get-ChildItem -LiteralPath artifacts/signed -Recurse -File) + $expectedNames = @('install.ps1', 'portcve.exe', 'SIGNING-METADATA.json') + if ($files.Count -ne $expectedNames.Count -or @($files.Name | Where-Object { $_ -notin $expectedNames }).Count -ne 0) { + throw "Signed transfer contains an unexpected file set: $($files.Name -join ', ')." + } + + ./scripts/Verify-ReleaseSignature.ps1 -Path artifacts/signed/portcve.exe -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT + ./scripts/Verify-ReleaseSignature.ps1 -Path artifacts/signed/install.ps1 -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT + + $metadata = Get-Content -LiteralPath artifacts/signed/SIGNING-METADATA.json -Raw | ConvertFrom-Json + $actualHash = (Get-FileHash -LiteralPath artifacts/signed/portcve.exe -Algorithm SHA256).Hash.ToLowerInvariant() + if ($metadata.schema_version -ne 1 -or + -not [StringComparer]::Ordinal.Equals([string]$metadata.artifact.sha256, $actualHash) -or + -not [StringComparer]::Ordinal.Equals([string]$metadata.signature.signer.subject, $env:EXPECTED_SIGNER_SUBJECT) -or + $metadata.signature.timestamp.binding_verified -ne $true -or + -not [StringComparer]::Ordinal.Equals([string]$metadata.signature.timestamp.binding_method, 'Rfc3161TimestampToken.VerifySignatureForSignerInfo') -or + [string]::IsNullOrWhiteSpace([string]$metadata.signature.timestamp.timestamp_utc) -or + -not [StringComparer]::Ordinal.Equals([string]$metadata.source.repository, $env:GITHUB_REPOSITORY) -or + -not [StringComparer]::OrdinalIgnoreCase.Equals([string]$metadata.source.commit_sha, $env:RELEASE_COMMIT) -or + -not [StringComparer]::Ordinal.Equals([string]$metadata.source.tag, $env:RELEASE_TAG)) { + throw 'Signing metadata does not bind the downloaded executable to this exact release context.' + } + + - name: Package signed release assets and checksums + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + $packageRoot = Join-Path $PWD 'artifacts/package' + $releaseRoot = Join-Path $PWD 'artifacts/release' + New-Item -ItemType Directory -Path $packageRoot, $releaseRoot | Out-Null + + Copy-Item -LiteralPath artifacts/signed/portcve.exe -Destination $packageRoot + Copy-Item -LiteralPath artifacts/signed/SIGNING-METADATA.json -Destination $packageRoot + foreach ($document in @('README.md', 'CHANGELOG.md', 'LICENSE', 'SECURITY.md')) { + Copy-Item -LiteralPath $document -Destination $packageRoot + } + Copy-Item -LiteralPath schema -Destination (Join-Path $packageRoot 'schema') -Recurse + + $zipName = "portcve-$($env:RELEASE_TAG)-win-x64.zip" + $zipPath = Join-Path $releaseRoot $zipName + Compress-Archive -Path (Join-Path $packageRoot '*') -DestinationPath $zipPath + Copy-Item -LiteralPath artifacts/signed/portcve.exe -Destination $releaseRoot + Copy-Item -LiteralPath artifacts/signed/install.ps1 -Destination $releaseRoot + Copy-Item -LiteralPath artifacts/signed/SIGNING-METADATA.json -Destination $releaseRoot + + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($zipPath) + try { + $zipExecutables = @($archive.Entries | Where-Object { + [StringComparer]::Ordinal.Equals($_.FullName.Replace('\', '/'), 'portcve.exe') + }) + if ($zipExecutables.Count -ne 1) { + throw "Portable ZIP must contain exactly one root portcve.exe; found $($zipExecutables.Count)." + } + $zipStream = $zipExecutables[0].Open() + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $zipExecutableHash = ([BitConverter]::ToString($sha256.ComputeHash($zipStream))).Replace('-', '').ToLowerInvariant() + } + finally { + $sha256.Dispose() + $zipStream.Dispose() + } + } + finally { + $archive.Dispose() + } + + $signedExecutableHash = (Get-FileHash -LiteralPath artifacts/signed/portcve.exe -Algorithm SHA256).Hash.ToLowerInvariant() + $signingMetadata = Get-Content -LiteralPath artifacts/signed/SIGNING-METADATA.json -Raw | ConvertFrom-Json + if (-not [StringComparer]::Ordinal.Equals($zipExecutableHash, $signedExecutableHash) -or + -not [StringComparer]::Ordinal.Equals([string]$signingMetadata.artifact.sha256, $signedExecutableHash)) { + throw 'Portable ZIP, standalone executable, and signing metadata do not contain the exact same signed executable.' + } + + $checksumTargets = @(Get-ChildItem -LiteralPath $releaseRoot -File | Sort-Object Name) + $checksumLines = @($checksumTargets | ForEach-Object { + $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $($_.Name)" + }) + [IO.File]::WriteAllLines((Join-Path $releaseRoot 'SHA256SUMS.txt'), $checksumLines, [Text.Encoding]::ASCII) + + ./scripts/Verify-ReleaseSignature.ps1 -Path (Join-Path $releaseRoot 'portcve.exe') -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT + ./scripts/Verify-ReleaseSignature.ps1 -Path (Join-Path $releaseRoot 'install.ps1') -ExpectedSignerSubject $env:EXPECTED_SIGNER_SUBJECT + + - name: Attest release asset provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: artifacts/release/* + + - name: Create verified draft release, then publish + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + $immutableJson = & gh api "repos/$env:GITHUB_REPOSITORY/immutable-releases" 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Could not verify immutable-releases configuration: $($immutableJson -join [Environment]::NewLine)" + } + $immutable = ($immutableJson -join [Environment]::NewLine) | ConvertFrom-Json + if ($immutable.enabled -ne $true) { + throw 'Immutable releases must be enabled before PortCVE can publish a release.' + } + + $existing = & gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$env:RELEASE_TAG" 2>&1 + $existingExit = $LASTEXITCODE + if ($existingExit -eq 0) { + throw "Release '$env:RELEASE_TAG' already exists; refusing to overwrite it." + } + if (($existing -join [Environment]::NewLine) -notmatch '(?i)\b404\b|not found') { + throw "Could not prove that the release tag is unused: $($existing -join [Environment]::NewLine)" + } + + $assets = @(Get-ChildItem -LiteralPath artifacts/release -File | Sort-Object Name) + $expectedAssetNames = @('install.ps1', 'portcve.exe', "portcve-$($env:RELEASE_TAG)-win-x64.zip", 'SHA256SUMS.txt', 'SIGNING-METADATA.json') | Sort-Object + if ($assets.Count -ne $expectedAssetNames.Count -or (Compare-Object @($assets.Name | Sort-Object) $expectedAssetNames)) { + throw "Release asset boundary is invalid: $($assets.Name -join ', ')." + } + + $createArgs = @('release', 'create', $env:RELEASE_TAG) + $createArgs += @($assets.FullName) + $createArgs += @('--draft', '--generate-notes', '--verify-tag', '--title', "PortCVE $env:RELEASE_TAG") + & gh @createArgs + if ($LASTEXITCODE -ne 0) { throw 'Creating the draft GitHub release failed.' } + + $draftJson = & gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$env:RELEASE_TAG" + if ($LASTEXITCODE -ne 0) { throw 'Could not read back the draft release.' } + $draft = ($draftJson -join [Environment]::NewLine) | ConvertFrom-Json + if ($draft.draft -ne $true) { throw 'The release was not created as a draft.' } + + $localDigests = @{} + foreach ($asset in $assets) { + $localDigests[$asset.Name] = "sha256:$((Get-FileHash -LiteralPath $asset.FullName -Algorithm SHA256).Hash.ToLowerInvariant())" + } + + $assetsVerified = $false + for ($attempt = 1; $attempt -le 5; $attempt++) { + $remoteJson = & gh api "repos/$env:GITHUB_REPOSITORY/releases/$($draft.id)/assets?per_page=100" + if ($LASTEXITCODE -ne 0) { throw 'Could not read back draft release assets.' } + $remoteAssets = @(($remoteJson -join [Environment]::NewLine) | ConvertFrom-Json) + $assetsVerified = $remoteAssets.Count -eq $assets.Count + foreach ($remote in $remoteAssets) { + if (-not $localDigests.ContainsKey([string]$remote.name) -or + -not [StringComparer]::OrdinalIgnoreCase.Equals([string]$remote.digest, [string]$localDigests[[string]$remote.name])) { + $assetsVerified = $false + } + } + if ($assetsVerified) { break } + Start-Sleep -Seconds 2 + } + if (-not $assetsVerified) { + throw 'GitHub did not report an exact SHA-256 digest match for every draft release asset.' + } + + if ([bool]::Parse($env:IS_PRERELEASE)) { + & gh release edit $env:RELEASE_TAG --draft=false --prerelease + } else { + & gh release edit $env:RELEASE_TAG --draft=false --latest + } + if ($LASTEXITCODE -ne 0) { throw 'Publishing the verified draft release failed.' } + + $publishedJson = & gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$env:RELEASE_TAG" + if ($LASTEXITCODE -ne 0) { throw 'Could not verify the published release state.' } + $published = ($publishedJson -join [Environment]::NewLine) | ConvertFrom-Json + $expectedPrerelease = [bool]::Parse($env:IS_PRERELEASE) + if ($published.draft -ne $false -or $published.prerelease -ne $expectedPrerelease) { + throw 'Published release state does not match the verified release policy.' + }