Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,18 @@ The manual lives in `src/site/antora/modules/ROOT/pages/`. A new appender page n
not one: the page itself, an `xref` line in `nav.adoc` (kept alphabetical), and the appender table
in `manual/configuration/appenders.adoc`.

## Release scripts

- **Every file `build-release.ps1` writes gets LF on every platform.** `Set-Content` writes
`[Environment]::NewLine`; use `-NoNewline` with the lines joined by `` `n ``. A CR is invisible in
an editor, and `sha512sum` on macOS reads it as part of the file name.
- **Each release ships the verifier built with it**, so never add backward compatibility to
`verify-release.ps1` or `.sh`: a compatibility skip path is a hole, not a courtesy.
- The scripts are tested with Pester through `scripts/FakeCommands.TestHelper.ps1`, which shadows
`dotnet`, `git`, `gpg`, `zip` and `mvnw` with shims on a prepended `PATH`, because Pester `Mock`
intercepts only functions and cmdlets. CI runs the suite on macOS, Ubuntu and Windows, so keep
the helper's `$IsWindows` branch working and put byte-level assertions where all three see them.

## Security findings

**[AGENTS.md](AGENTS.md) decides whether something is in scope and whether it is a vulnerability.**
Expand Down
23 changes: 20 additions & 3 deletions scripts/FakeCommands.TestHelper.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,24 @@
commands ran without building, signing, tagging or pushing anything.
#>

# The commit the fake "git rev-parse" reports, so a test can assert what was archived and recorded.
$script:FakeCommitHash = 'a1b2c3d4e5f6071829a3b4c5d6e7f80912345678'

# Logs its call, fails when named in FAKE_FAIL, and otherwise writes the files the real command
# would, where it is told to, so a wrong output path fails as it would for real.
# would, where it is told to, so a wrong output path fails as it would for real. "git status" and
# "git rev-parse" answer on stdout instead, FAKE_DIRTY faking an unclean tree.
$script:FakeCommand = @'
param([string]$Name)
Add-Content -Path $env:FAKE_LOG -Value "$Name $($args -join ' ')"
if ($env:FAKE_FAIL -eq $Name) { exit 1 }
if ($Name -eq 'git')
{
switch ($args[0])
{
'status' { if ($env:FAKE_DIRTY) { ' M src/log4net/Core/LogImpl.cs' } }
'rev-parse' { $env:FAKE_SHA }
}
}
$outputs = switch ($Name)
{
'dotnet'
Expand Down Expand Up @@ -61,7 +73,8 @@ function New-ScratchTree

function Invoke-InScratchTree
{
param ([Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)][string]$Script, [string]$Fail)
param ([Parameter(Mandatory)][string]$Root, [Parameter(Mandatory)][string]$Script, [string]$Fail,
[switch]$Dirty)

$log = Join-Path $Root 'calls.log'
New-Item -ItemType File -Path $log -Force | Out-Null
Expand All @@ -71,18 +84,22 @@ function Invoke-InScratchTree
$env:PATH = (Join-Path $Root 'fakebin') + [System.IO.Path]::PathSeparator + $path
$env:FAKE_LOG = $log
$env:FAKE_FAIL = $Fail
$env:FAKE_SHA = $script:FakeCommitHash
$env:FAKE_DIRTY = $Dirty ? '1' : ''
# NonInteractive makes the confirmation pause throw, standing in for a release manager who says no.
$output = pwsh -NoProfile -NonInteractive -File (Join-Path $Root 'scripts' $Script) 2>&1
$exitCode = $LASTEXITCODE
}
finally
{
$env:PATH = $path
Remove-Item Env:FAKE_LOG, Env:FAKE_FAIL -ErrorAction SilentlyContinue
Remove-Item Env:FAKE_LOG, Env:FAKE_FAIL, Env:FAKE_SHA, Env:FAKE_DIRTY -ErrorAction SilentlyContinue
}
return [pscustomobject]@{
ExitCode = $exitCode
Calls = @(Get-Content $log | ForEach-Object { ($_ -split ' ')[0..1] -join ' ' })
# The whole logged line, for an assertion about an argument rather than about the command.
Lines = @(Get-Content $log)
Output = $output -join [Environment]::NewLine
}
}
3 changes: 2 additions & 1 deletion scripts/build-preview.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

param(
$Version = '3.5.0',
$Preview = '1'
[ValidateRange('Positive')]
[int]$Preview = 1
)

Set-StrictMode -Version Latest
Expand Down
65 changes: 59 additions & 6 deletions scripts/build-release.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ Describe 'build-release.ps1' {
$result = Invoke-InScratchTree -Root $script:Root -Script 'build-release.ps1' -Fail 'dotnet'

$result.ExitCode | Should -Not -Be 0
$result.Calls | Should -Be @('dotnet test')
$result.Calls | Should -Be @('git status', 'git rev-parse', 'dotnet test')
}

It 'builds no site when signing fails' {
$result = Invoke-InScratchTree -Root $script:Root -Script 'build-release.ps1' -Fail 'gpg'

$result.ExitCode | Should -Not -Be 0
$result.Calls | Should -Be @('dotnet test', 'git archive', 'zip -r', 'gpg --armor')
$result.Calls | Should -Be @('git status', 'git rev-parse', 'dotnet test', 'git archive', 'zip -r',
'gpg --armor')
}

It 'asks for no tag when the site build fails' {
Expand All @@ -46,20 +47,72 @@ Describe 'build-release.ps1' {
$result.Calls[-1] | Should -Be 'mvnw site'
}

It 'signs all six artifacts and tags nothing without confirmation' {
It 'signs all seven artifacts and tags nothing without confirmation' {
$result = Invoke-InScratchTree -Root $script:Root -Script 'build-release.ps1'

$result.ExitCode | Should -Not -Be 0
$result.Output | Should -BeLike '*NonInteractive*'
$result.Calls | Should -Be @('dotnet test', 'git archive', 'zip -r',
'gpg --armor', 'gpg --armor', 'gpg --armor', 'gpg --armor', 'gpg --armor', 'gpg --armor', 'mvnw site')
$result.Calls | Should -Be @('git status', 'git rev-parse', 'dotnet test', 'git archive', 'zip -r',
'gpg --armor', 'gpg --armor', 'gpg --armor', 'gpg --armor', 'gpg --armor', 'gpg --armor',
'gpg --armor', 'mvnw site')
}

It 'ships no artifact without a hash' {
Invoke-InScratchTree -Root $script:Root -Script 'build-release.ps1' | Out-Null

$artifacts = Get-ChildItem (Join-Path $script:Root 'build' 'artifacts') -Exclude '*.sha512', '*.asc'
$artifacts.Name | Should -HaveCount 6
$artifacts.Name | Should -HaveCount 7
$artifacts | Where-Object { !(Test-Path "$($_.FullName).sha512") } | Should -BeNullOrEmpty
}

It 'writes every hash as one LF-terminated line' {
Invoke-InScratchTree -Root $script:Root -Script 'build-release.ps1' | Out-Null

$hashes = Get-ChildItem (Join-Path $script:Root 'build' 'artifacts') -Filter '*.sha512'
$hashes | Should -HaveCount 7
foreach ($hash in $hashes)
{
[System.IO.File]::ReadAllText($hash.FullName) | Should -MatchExactly '^[0-9a-f]{128} \*\./\S+\n\z'
}
}

It 'removes the artifacts of an earlier run' {
$stale = Join-Path $script:Root 'build' 'artifacts' 'apache-log4net-0.0.0.nupkg'
New-Item -ItemType File -Force -Path $stale | Out-Null

Invoke-InScratchTree -Root $script:Root -Script 'build-release.ps1' | Out-Null

Test-Path $stale | Should -BeFalse
}

It 'builds nothing when the working tree is dirty' {
$result = Invoke-InScratchTree -Root $script:Root -Script 'build-release.ps1' -Dirty

$result.ExitCode | Should -Not -Be 0
$result.Calls | Should -Be @('git status')
}

It 'archives the commit it built' {
$result = Invoke-InScratchTree -Root $script:Root -Script 'build-release.ps1'

$archive = @($result.Lines | Where-Object { $_ -like 'git archive*' })
$archive | Should -HaveCount 1
$archive[0] | Should -BeLike "* $script:FakeCommitHash"
}

It 'records the commit and the artifact set in the manifest' {
Invoke-InScratchTree -Root $script:Root -Script 'build-release.ps1' | Out-Null

$artifacts = Join-Path $script:Root 'build' 'artifacts'
$manifest = Get-ChildItem $artifacts -Filter '*.manifest'
$manifest | Should -HaveCount 1
$lines = Get-Content $manifest.FullName
$lines | Should -Contain "commit=$script:FakeCommitHash"
$listed = @($lines | Where-Object { $_ -like 'artifact=*' })
$listed | Should -HaveCount 7
$present = @(Get-ChildItem $artifacts -Exclude '*.sha512', '*.asc' | ForEach-Object { "artifact=$($_.Name)" })
$listed | Sort-Object | Should -Be ($present | Sort-Object)
# LF on every platform, or verify-release.sh compares names with a trailing CR.
[System.IO.File]::ReadAllText($manifest.FullName) | Should -Not -BeLike "*`r*"
}
}
146 changes: 119 additions & 27 deletions scripts/build-release.ps1
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#Requires -Version 7.4

param(
$Version = '3.5.0'
$Version = '3.5.0',
[ValidateRange('Positive')]
[int]$Rc = 1
)

Set-StrictMode -Version Latest
Expand All @@ -11,6 +13,89 @@ $ErrorActionPreference = 'Stop'
# Only honored from PowerShell 7.4, hence the #Requires above.
$PSNativeCommandUseErrorActionPreference = $true

$Root = "$PSScriptRoot/.."
$ArtifactDirectory = "$Root/build/artifacts"
$ManifestName = "apache-log4net-$Version.manifest"
$ArtifactNames = @(
"apache-log4net.$Version.nupkg",
"apache-log4net.Ext.Mail.$Version.nupkg",
"apache-log4net-source-$Version.zip",
"apache-log4net-binaries-$Version.zip",
'verify-release.ps1',
'verify-release.sh',
$ManifestName)

# Paired, so a throw inside cannot leave the caller in the wrong directory.
function Invoke-InDirectory
{
param
(
[Parameter(Mandatory=$true, HelpMessage='The directory to run in.')]
[string]$Directory,
[Parameter(Mandatory=$true, HelpMessage='What to run there.')]
[scriptblock]$Action
)

Push-Location $Directory
try
{
& $Action
}
finally
{
Pop-Location
}
}

# The binaries come from the working tree, the source archive from a git ref, and both are signed
# as one release, so they must come from one commit.
function Get-ReleaseCommit
{
Invoke-InDirectory $Root {
$GitStatus = git status --porcelain
if ($GitStatus)
{
throw "the working tree is not clean, so the binaries and the source archive would not match:$([Environment]::NewLine)$($GitStatus -join [Environment]::NewLine)"
}

git rev-parse --verify HEAD
}
}

# Records the commit and the artifact set, and is signed with them. Without the set a missing
# artifact goes unnoticed, since the verifiers can only check the files that are there.
function Write-Manifest
{
param
(
[Parameter(Mandatory=$true, HelpMessage='The commit the release was built from.')]
[string]$Commit,
[Parameter(Mandatory=$true, HelpMessage='The artifact names, the manifest included.')]
[string[]]$Names
)

# LF on every platform: Set-Content would write CRLF on Windows and the artifact names would then
# carry a trailing CR into the comparison in verify-release.sh.
$Lines = @("commit=$Commit") + ($Names | ForEach-Object { "artifact=$_" })
Set-Content -Path $ArtifactDirectory/$ManifestName -NoNewline -Value (($Lines -join "`n") + "`n")
}

# Tested rather than silenced: -ErrorAction SilentlyContinue would also swallow a delete that
# failed, leaving a stale artifact behind for whoever copies the directory to dist.
function Remove-Directory
{
param
(
[Parameter(Mandatory=$true, HelpMessage='The directory to remove if it exists.')]
[string]$Directory
)

if (Test-Path $Directory)
{
Remove-Item $Directory -Force -Recurse
}
}

function Write-HashAndSignature
{
param
Expand All @@ -21,42 +106,49 @@ function Write-HashAndSignature
$File.FullName
$ComputedHash = (Get-FileHash -Algorithm 'SHA512' $File).Hash.ToLowerInvariant()
$ComputedHash
Set-Content -Path "$($File.FullName).sha512" -Value "$ComputedHash *./$($File.Name)"
# LF on every platform: the macOS sha512sum reads a CR from a Windows build as part of the file name.
Set-Content -NoNewline -Path "$($File.FullName).sha512" -Value "$ComputedHash *./$($File.Name)`n"
gpg --armor --output "$($File.FullName).asc" --detach-sig $File.FullName
}

"cleaning $PSScriptRoot/../build/ ..."
Remove-Item $PSScriptRoot/../build/ -Force -Recurse -ErrorAction SilentlyContinue
"cleaning $Root/build/ ..."
Remove-Directory $Root/build/

'verifying release tree ...'
$CommitHash = Get-ReleaseCommit

'building ...'
dotnet test -c Release "-p:GeneratePackages=true;PackageVersion=$Version" $PSScriptRoot/../src/log4net.sln
dotnet test -c Release "-p:GeneratePackages=true;PackageVersion=$Version" $Root/src/log4net.sln

'compressing source ...'
pushd $PSScriptRoot/..
git archive --format=zip --output $PSScriptRoot/../build/artifacts/apache-log4net-source-$Version.zip master
popd
Invoke-InDirectory $Root {
git archive --format=zip --output $ArtifactDirectory/apache-log4net-source-$Version.zip $CommitHash
}

'compressing binaries ...'
Copy-Item $PSScriptRoot/verify-release.ps1, $PSScriptRoot/verify-release.sh $PSScriptRoot/../build/artifacts/
Copy-Item $PSScriptRoot/../LICENSE $PSScriptRoot/../build/Release/
Copy-Item $PSScriptRoot/../NOTICE $PSScriptRoot/../build/Release/
pushd $PSScriptRoot/../build/Release
zip -r $PSScriptRoot/../build/artifacts/apache-log4net-binaries-$Version.zip .
popd
Copy-Item $PSScriptRoot/verify-release.ps1, $PSScriptRoot/verify-release.sh $ArtifactDirectory/
Copy-Item $Root/LICENSE, $Root/NOTICE $Root/build/Release/
Invoke-InDirectory $Root/build/Release {
zip -r $ArtifactDirectory/apache-log4net-binaries-$Version.zip .
}

'signing ...'
Move-Item $PSScriptRoot/../build/artifacts/log4net.$Version.nupkg $PSScriptRoot/../build/artifacts/apache-log4net.$Version.nupkg
Write-HashAndSignature $PSScriptRoot/../build/artifacts/apache-log4net.$Version.nupkg
Move-Item $PSScriptRoot/../build/artifacts/log4net.Ext.Mail.$Version.nupkg $PSScriptRoot/../build/artifacts/apache-log4net.Ext.Mail.$Version.nupkg
Write-HashAndSignature $PSScriptRoot/../build/artifacts/apache-log4net.Ext.Mail.$Version.nupkg
Write-HashAndSignature $PSScriptRoot/../build/artifacts/apache-log4net-source-$Version.zip
Write-HashAndSignature $PSScriptRoot/../build/artifacts/apache-log4net-binaries-$Version.zip
Write-HashAndSignature $PSScriptRoot/../build/artifacts/verify-release.ps1
Write-HashAndSignature $PSScriptRoot/../build/artifacts/verify-release.sh
Move-Item $ArtifactDirectory/log4net.$Version.nupkg $ArtifactDirectory/apache-log4net.$Version.nupkg
Move-Item $ArtifactDirectory/log4net.Ext.Mail.$Version.nupkg $ArtifactDirectory/apache-log4net.Ext.Mail.$Version.nupkg
Write-Manifest -Commit $CommitHash -Names $ArtifactNames
foreach ($ArtifactName in $ArtifactNames)
{
Write-HashAndSignature $ArtifactDirectory/$ArtifactName
}

'cleaning site ...'
Remove-Item $PSScriptRoot/../target/ -Force -Recurse -ErrorAction SilentlyContinue
Remove-Directory $Root/target/

'building site ...'
pushd $PSScriptRoot/..
./mvnw site
popd
Invoke-InDirectory $Root { ./mvnw site }

'creating tag ...'
pause
git tag "rc/$Version-rc1"
git tag "rc/$Version-rc$Rc"
'pushing tag ...'
git push --tags
7 changes: 6 additions & 1 deletion scripts/sign-log4net-libraries.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
#!/bin/bash
# see https://infra.apache.org/release-signing#openpgp-ascii-detach-sig
set -euo pipefail

# Without nullglob an empty directory iterates the patterns, so the guard below never fires.
shopt -s nullglob

DID_SOMETHING=0
for f in *log4net*.nupkg *log4net*.zip; do
DID_SOMETHING=1
echo "signing: $f"
gpg --armor --output $f.asc --detach-sig $f
gpg --armor --output "$f.asc" --detach-sig "$f"
done

if test "$DID_SOMETHING" = "0"; then
Expand Down
Loading
Loading