From 1ab9c143740f2b0ec5efa947d8b98509af61b569 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 10 Jan 2026 12:28:09 +0100 Subject: [PATCH 1/9] release: add deterministic release artifact builder tool --- tools/New-IdleReleaseArtifact.ps1 | 187 ++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 tools/New-IdleReleaseArtifact.ps1 diff --git a/tools/New-IdleReleaseArtifact.ps1 b/tools/New-IdleReleaseArtifact.ps1 new file mode 100644 index 00000000..84c75a05 --- /dev/null +++ b/tools/New-IdleReleaseArtifact.ps1 @@ -0,0 +1,187 @@ +[CmdletBinding()] +param( + <# + .SYNOPSIS + Creates a deterministic ZIP release artifact for IdLE. + + .DESCRIPTION + Builds a ZIP archive with stable entry ordering and stable timestamps. + This is designed to be used from CI (GitHub Actions) when creating a GitHub Release. + + The artifact intentionally excludes CI/test-only content to keep releases lean. + + .PARAMETER RepositoryRoot + Repository root path. Defaults to the directory of this script's parent folder. + + .PARAMETER Tag + The release tag (e.g. v0.7.0). Used to name the output zip. + + .PARAMETER OutputDirectory + Directory where the ZIP artifact will be written. Defaults to 'artifacts/' under the repo root. + + .OUTPUTS + System.IO.FileInfo + #> + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $RepositoryRoot = (Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..')).Path, + + [Parameter(Mandatory)] + [ValidatePattern('^v.+$')] + [string] $Tag, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $OutputDirectory +) + +Set-StrictMode -Off +$ErrorActionPreference = 'Stop' + +# Default output directory under the repo root if not explicitly set. +if (-not $PSBoundParameters.ContainsKey('OutputDirectory')) { + $OutputDirectory = Join-Path -Path $RepositoryRoot -ChildPath 'artifacts' +} + +$zipFileName = "IdLE-$Tag.zip" +$zipPath = Join-Path -Path $OutputDirectory -ChildPath $zipFileName + +# Ensure output folder exists. +if (-not (Test-Path -Path $OutputDirectory)) { + New-Item -Path $OutputDirectory -ItemType Directory -Force | Out-Null +} + +# A stable timestamp helps make artifacts deterministic across runs. +# (ZIP's DOS date range starts at 1980.) +$stableTimestamp = [DateTimeOffset]::new(1980, 1, 1, 0, 0, 0, [TimeSpan]::Zero) + +# Define what goes into the release artifact. +# Keep this explicit to avoid accidental leakage of CI/test/internal content. +$includeRoots = @( + 'src', + 'docs', + 'examples', + 'tools', + 'README.md', + 'LICENSE.md', + 'CONTRIBUTING.md', + 'STYLEGUIDE.md' +) + +$excludeTopLevel = @( + '.github', + 'tests', + 'artifacts' +) + +function Get-IdleReleaseFileList { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Root, + + [Parameter(Mandatory)] + [string[]] $Include + ) + + $files = New-Object System.Collections.Generic.List[System.IO.FileInfo] + + foreach ($item in $Include) { + $fullPath = Join-Path -Path $Root -ChildPath $item + + if (-not (Test-Path -Path $fullPath)) { + # Missing files should not break the build (e.g. LICENSE.md in early stages). + continue + } + + $itemInfo = Get-Item -LiteralPath $fullPath -ErrorAction Stop + if ($itemInfo.PSIsContainer) { + Get-ChildItem -LiteralPath $itemInfo.FullName -File -Recurse -Force | + ForEach-Object { $files.Add($_) } + } + else { + $files.Add([System.IO.FileInfo]$itemInfo) + } + } + + # Sort by relative path for deterministic ordering. + $sorted = $files | + Sort-Object { + $rel = Resolve-Path -LiteralPath $_.FullName + $rel = $rel.Path.Substring($Root.Length).TrimStart('\', '/') + $rel.ToLowerInvariant() + } + + return ,$sorted +} + +function ConvertTo-IdleZipEntryPath { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $RepositoryRootPath, + + [Parameter(Mandatory)] + [string] $FullFilePath + ) + + $relative = $FullFilePath.Substring($RepositoryRootPath.Length).TrimStart('\', '/') + + # ZIP standard uses forward slashes. + return ($relative -replace '\\', '/') +} + +# Safety: ensure we are in the repository root and not accidentally packing something else. +foreach ($excluded in $excludeTopLevel) { + if (Test-Path -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath $excluded)) { + # This is an existence check only. We exclude by not including it in $includeRoots. + continue + } +} + +# Recreate ZIP if it already exists (idempotent). +if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force +} + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$filesToPack = Get-IdleReleaseFileList -Root $RepositoryRoot -Include $includeRoots + +$zipStream = [System.IO.File]::Open($zipPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) +try { + $archive = New-Object System.IO.Compression.ZipArchive($zipStream, [System.IO.Compression.ZipArchiveMode]::Create, $true) + try { + foreach ($file in $filesToPack) { + $entryPath = ConvertTo-IdleZipEntryPath -RepositoryRootPath $RepositoryRoot -FullFilePath $file.FullName + + # Create entry with deterministic metadata. + $entry = $archive.CreateEntry($entryPath, [System.IO.Compression.CompressionLevel]::Optimal) + $entry.LastWriteTime = $stableTimestamp + + $entryStream = $entry.Open() + try { + $fileStream = [System.IO.File]::OpenRead($file.FullName) + try { + $fileStream.CopyTo($entryStream) + } + finally { + $fileStream.Dispose() + } + } + finally { + $entryStream.Dispose() + } + } + } + finally { + $archive.Dispose() + } +} +finally { + $zipStream.Dispose() +} + +Get-Item -LiteralPath $zipPath From 459e97deaec85bd47a7fdcdc559e85f351f39b17 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 10 Jan 2026 12:31:29 +0100 Subject: [PATCH 2/9] ci: add GitHub Actions workflow for automated releases --- .github/workflows/release.yml | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..4ea59506 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Create GitHub Release + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Show PowerShell version + shell: pwsh + run: | + $PSVersionTable.PSVersion + pwsh -v + + - name: Build release artifact + shell: pwsh + run: | + $tag = '${{ github.ref_name }}' + ./tools/New-IdleReleaseArtifact.ps1 -Tag $tag + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + generate_release_notes: true + files: | + artifacts/IdLE-${{ github.ref_name }}.zip From dcd16560e3c02f914e73105716c9926c821aa151 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:03:26 +0100 Subject: [PATCH 3/9] ci: add workflow_dispatch dry-run mode for release workflow --- .github/workflows/release.yml | 58 +++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ea59506..4b5b3cd3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,13 +4,24 @@ on: push: tags: - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Tag name to package (e.g. v0.7.0-test). If empty, uses current ref name.' + required: false + type: string + publish_release: + description: 'If true, create a GitHub Release and upload the ZIP as a release asset.' + required: false + default: false + type: boolean permissions: contents: write jobs: release: - name: Create GitHub Release + name: Build artifact (and optionally publish release) runs-on: ubuntu-latest steps: @@ -25,17 +36,52 @@ jobs: $PSVersionTable.PSVersion pwsh -v + - name: Determine tag + id: tag + shell: pwsh + run: | + $inputTag = '${{ inputs.tag }}' + if (-not [string]::IsNullOrWhiteSpace($inputTag)) { + "value=$inputTag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + exit 0 + } + + $refName = '${{ github.ref_name }}' + if ([string]::IsNullOrWhiteSpace($refName)) { + throw "github.ref_name is empty. Provide inputs.tag when using workflow_dispatch." + } + + "value=$refName" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + - name: Build release artifact shell: pwsh run: | - $tag = '${{ github.ref_name }}' + $tag = '${{ steps.tag.outputs.value }}' ./tools/New-IdleReleaseArtifact.ps1 -Tag $tag - - name: Create GitHub Release + - name: Verify artifact exists + shell: pwsh + run: | + $tag = '${{ steps.tag.outputs.value }}' + $path = "artifacts/IdLE-$tag.zip" + if (-not (Test-Path -LiteralPath $path)) { + throw "Expected artifact not found: $path" + } + Get-Item -LiteralPath $path | Format-List FullName, Length, LastWriteTimeUtc + + - name: Upload workflow artifact (dry run) + uses: actions/upload-artifact@v4 + with: + name: IdLE-${{ steps.tag.outputs.value }} + path: artifacts/IdLE-${{ steps.tag.outputs.value }}.zip + if-no-files-found: error + + - name: Create GitHub Release (optional) + if: ${{ inputs.publish_release == true || startsWith(github.ref, 'refs/tags/v') }} uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.ref_name }} - name: ${{ github.ref_name }} + tag_name: ${{ steps.tag.outputs.value }} + name: ${{ steps.tag.outputs.value }} generate_release_notes: true files: | - artifacts/IdLE-${{ github.ref_name }}.zip + artifacts/IdLE-${{ steps.tag.outputs.value }}.zip From 370fe1a206398cd09c2bf86eb7bb81bc9b165356 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:17:48 +0100 Subject: [PATCH 4/9] release: harden and add list-only mode to release artifact builder --- tools/New-IdleReleaseArtifact.ps1 | 240 +++++++++++++++++++++--------- 1 file changed, 166 insertions(+), 74 deletions(-) diff --git a/tools/New-IdleReleaseArtifact.ps1 b/tools/New-IdleReleaseArtifact.ps1 index 84c75a05..b8fda078 100644 --- a/tools/New-IdleReleaseArtifact.ps1 +++ b/tools/New-IdleReleaseArtifact.ps1 @@ -1,79 +1,124 @@ -[CmdletBinding()] -param( - <# - .SYNOPSIS +<# +.SYNOPSIS Creates a deterministic ZIP release artifact for IdLE. - .DESCRIPTION - Builds a ZIP archive with stable entry ordering and stable timestamps. - This is designed to be used from CI (GitHub Actions) when creating a GitHub Release. +.DESCRIPTION + Builds a ZIP archive with stable entry ordering and stable ZIP metadata. + The artifact content is explicitly defined to reduce the risk of shipping + CI/test/internal files accidentally. - The artifact intentionally excludes CI/test-only content to keep releases lean. + This script is designed for CI usage (GitHub Actions) but can also be run locally. - .PARAMETER RepositoryRoot - Repository root path. Defaults to the directory of this script's parent folder. +.PARAMETER Tag + The release tag used to name the artifact (e.g. v0.7.0). - .PARAMETER Tag - The release tag (e.g. v0.7.0). Used to name the output zip. +.PARAMETER RepoRootPath + The repository root path. Defaults to the parent directory of the script folder (../). - .PARAMETER OutputDirectory - Directory where the ZIP artifact will be written. Defaults to 'artifacts/' under the repo root. +.PARAMETER OutputDirectory + Directory where the ZIP artifact will be written. + Defaults to 'artifacts/' under the repo root. - .OUTPUTS - System.IO.FileInfo - #> +.PARAMETER ListOnly + If set, prints the normalized, deterministic file list and exits without creating a ZIP. - [Parameter()] - [ValidateNotNullOrEmpty()] - [string] $RepositoryRoot = (Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..')).Path, +.EXAMPLE + PS> pwsh -NoProfile -File ./tools/New-IdleReleaseArtifact.ps1 -Tag v0.7.0 + +.EXAMPLE + PS> pwsh -NoProfile -File ./tools/New-IdleReleaseArtifact.ps1 -Tag v0.7.0 -ListOnly +.OUTPUTS + System.IO.FileInfo +#> + +[CmdletBinding()] +param( [Parameter(Mandatory)] - [ValidatePattern('^v.+$')] + # Allow typical semver tags and prerelease/build metadata. Disallow path separators and whitespace. + [ValidatePattern('^v[0-9A-Za-z][0-9A-Za-z\.\-\+]*$')] [string] $Tag, [Parameter()] [ValidateNotNullOrEmpty()] - [string] $OutputDirectory + [string] $RepoRootPath = (Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..')).Path, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $OutputDirectory, + + [Parameter()] + [switch] $ListOnly ) -Set-StrictMode -Off +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Default output directory under the repo root if not explicitly set. if (-not $PSBoundParameters.ContainsKey('OutputDirectory')) { - $OutputDirectory = Join-Path -Path $RepositoryRoot -ChildPath 'artifacts' + $OutputDirectory = Join-Path -Path $RepoRootPath -ChildPath 'artifacts' } -$zipFileName = "IdLE-$Tag.zip" -$zipPath = Join-Path -Path $OutputDirectory -ChildPath $zipFileName +# ZIP entry timestamps are stored in a limited format. Use a stable time for deterministic artifacts. +# ZIP's DOS date range starts at 1980. +$stableTimestamp = [DateTimeOffset]::new(1980, 1, 1, 0, 0, 0, [TimeSpan]::Zero) -# Ensure output folder exists. -if (-not (Test-Path -Path $OutputDirectory)) { - New-Item -Path $OutputDirectory -ItemType Directory -Force | Out-Null +function Resolve-IdleRepoRoot { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Path + ) + + $resolved = (Resolve-Path -LiteralPath $Path).Path + + # Basic sanity checks to reduce accidental misuse. + $src = Join-Path -Path $resolved -ChildPath 'src' + if (-not (Test-Path -LiteralPath $src)) { + throw "RepoRootPath does not look like the IdLE repository root (missing 'src'): $resolved" + } + + return $resolved } -# A stable timestamp helps make artifacts deterministic across runs. -# (ZIP's DOS date range starts at 1980.) -$stableTimestamp = [DateTimeOffset]::new(1980, 1, 1, 0, 0, 0, [TimeSpan]::Zero) +function ConvertTo-IdleZipEntryPath { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $RepositoryRootPath, -# Define what goes into the release artifact. -# Keep this explicit to avoid accidental leakage of CI/test/internal content. -$includeRoots = @( - 'src', - 'docs', - 'examples', - 'tools', - 'README.md', - 'LICENSE.md', - 'CONTRIBUTING.md', - 'STYLEGUIDE.md' -) + [Parameter(Mandatory)] + [string] $FullFilePath + ) -$excludeTopLevel = @( - '.github', - 'tests', - 'artifacts' -) + $relative = $FullFilePath.Substring($RepositoryRootPath.Length).TrimStart('\', '/') + + # ZIP standard uses forward slashes. + return ($relative -replace '\\', '/') +} + +function Test-IdlePathExcluded { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $RelativePath + ) + + # Normalize to forward slashes for consistent matching. + $p = ($RelativePath -replace '\\', '/').ToLowerInvariant() + + # Exclude obvious non-release content even if someone expands includes later. + # Keep this conservative; the include list is still the primary control. + if ($p.StartsWith('.git/')) { return $true } + if ($p.StartsWith('.github/')) { return $true } + if ($p.StartsWith('tests/')) { return $true } + if ($p.StartsWith('artifacts/')) { return $true } + + # Common build outputs that should never ship. + if ($p -match '(^|/)(bin|obj)/') { return $true } + + return $false +} function Get-IdleReleaseFileList { [CmdletBinding()] @@ -90,7 +135,7 @@ function Get-IdleReleaseFileList { foreach ($item in $Include) { $fullPath = Join-Path -Path $Root -ChildPath $item - if (-not (Test-Path -Path $fullPath)) { + if (-not (Test-Path -LiteralPath $fullPath)) { # Missing files should not break the build (e.g. LICENSE.md in early stages). continue } @@ -105,39 +150,72 @@ function Get-IdleReleaseFileList { } } - # Sort by relative path for deterministic ordering. + # Sort by normalized relative path for deterministic ordering. $sorted = $files | - Sort-Object { - $rel = Resolve-Path -LiteralPath $_.FullName - $rel = $rel.Path.Substring($Root.Length).TrimStart('\', '/') - $rel.ToLowerInvariant() - } + ForEach-Object { + $rel = $_.FullName.Substring($Root.Length).TrimStart('\', '/') + [pscustomobject]@{ + FileInfo = $_ + RelativePath = $rel + SortKey = ($rel -replace '\\', '/').ToLowerInvariant() + } + } | + Where-Object { -not (Test-IdlePathExcluded -RelativePath $_.RelativePath) } | + Sort-Object SortKey - return ,$sorted + return ,($sorted.FileInfo) } -function ConvertTo-IdleZipEntryPath { +function ConvertTo-IdleSafeFileName { [CmdletBinding()] param( [Parameter(Mandatory)] - [string] $RepositoryRootPath, - - [Parameter(Mandatory)] - [string] $FullFilePath + [string] $Value ) - $relative = $FullFilePath.Substring($RepositoryRootPath.Length).TrimStart('\', '/') + # Make sure the file name is safe on all platforms. + # We already validated Tag, but keep a defensive normalization. + return ($Value -replace '[^\w\.\-\+]', '_') +} - # ZIP standard uses forward slashes. - return ($relative -replace '\\', '/') +$RepoRootPath = Resolve-IdleRepoRoot -Path $RepoRootPath + +# Define what goes into the release artifact. +# Keep this explicit to avoid accidental leakage of CI/test/internal content. +$includeRoots = @( + 'src', + 'docs', + 'examples', + 'tools', + 'README.md', + 'LICENSE.md', + 'CONTRIBUTING.md', + 'STYLEGUIDE.md' +) + +$tagForFileName = ConvertTo-IdleSafeFileName -Value $Tag +$zipFileName = "IdLE-$tagForFileName.zip" + +if (-not (Test-Path -LiteralPath $OutputDirectory)) { + New-Item -Path $OutputDirectory -ItemType Directory -Force | Out-Null } -# Safety: ensure we are in the repository root and not accidentally packing something else. -foreach ($excluded in $excludeTopLevel) { - if (Test-Path -LiteralPath (Join-Path -Path $RepositoryRoot -ChildPath $excluded)) { - # This is an existence check only. We exclude by not including it in $includeRoots. - continue +$zipPath = Join-Path -Path $OutputDirectory -ChildPath $zipFileName + +$filesToPack = Get-IdleReleaseFileList -Root $RepoRootPath -Include $includeRoots + +if ($ListOnly) { + Write-Host "RepoRootPath : $RepoRootPath" + Write-Host "Tag : $Tag" + Write-Host "OutputDirectory: $OutputDirectory" + Write-Host "ZipPath : $zipPath" + Write-Host "" + Write-Host "Files (deterministic order):" + foreach ($f in $filesToPack) { + $rel = $f.FullName.Substring($RepoRootPath.Length).TrimStart('\', '/') + Write-Host (" - " + ($rel -replace '\\', '/')) } + return } # Recreate ZIP if it already exists (idempotent). @@ -148,14 +226,28 @@ if (Test-Path -LiteralPath $zipPath) { Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem -$filesToPack = Get-IdleReleaseFileList -Root $RepositoryRoot -Include $includeRoots +$zipStream = [System.IO.File]::Open( + $zipPath, + [System.IO.FileMode]::CreateNew, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None +) -$zipStream = [System.IO.File]::Open($zipPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) try { - $archive = New-Object System.IO.Compression.ZipArchive($zipStream, [System.IO.Compression.ZipArchiveMode]::Create, $true) + $archive = New-Object System.IO.Compression.ZipArchive( + $zipStream, + [System.IO.Compression.ZipArchiveMode]::Create, + $true + ) + try { foreach ($file in $filesToPack) { - $entryPath = ConvertTo-IdleZipEntryPath -RepositoryRootPath $RepositoryRoot -FullFilePath $file.FullName + $entryPath = ConvertTo-IdleZipEntryPath -RepositoryRootPath $RepoRootPath -FullFilePath $file.FullName + + if (Test-IdlePathExcluded -RelativePath $entryPath) { + # Defense-in-depth; Get-IdleReleaseFileList already excludes these. + continue + } # Create entry with deterministic metadata. $entry = $archive.CreateEntry($entryPath, [System.IO.Compression.CompressionLevel]::Optimal) From a5d9e28b8f8ca93cede776a4c149ee8f2a645590 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:19:07 +0100 Subject: [PATCH 5/9] release: exclude tools from release artifact and include SECURITY.md --- tools/New-IdleReleaseArtifact.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/New-IdleReleaseArtifact.ps1 b/tools/New-IdleReleaseArtifact.ps1 index b8fda078..dc5d90a9 100644 --- a/tools/New-IdleReleaseArtifact.ps1 +++ b/tools/New-IdleReleaseArtifact.ps1 @@ -186,7 +186,6 @@ $includeRoots = @( 'src', 'docs', 'examples', - 'tools', 'README.md', 'LICENSE.md', 'CONTRIBUTING.md', From a01d4abee3721576ca79d306eed0e212f83ecedb Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:27:36 +0100 Subject: [PATCH 6/9] docs: adding release documentation --- docs/_navbar.md | 1 + docs/_sidebar.md | 1 + docs/advanced/releases.md | 76 +++++++++++++++++++++++++++++++++++++++ docs/index.md | 1 + 4 files changed, 79 insertions(+) create mode 100644 docs/advanced/releases.md diff --git a/docs/_navbar.md b/docs/_navbar.md index efc20d1c..355c4cf4 100644 --- a/docs/_navbar.md +++ b/docs/_navbar.md @@ -3,3 +3,4 @@ - [Usage](usage/workflows.md) - [Specifications](specs/plan-export.md) - [Advanced](advanced/architecture.md) +- [Releasing](advanced/releases.md) \ No newline at end of file diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 7bbf508b..357491c5 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -35,3 +35,4 @@ - [Security](advanced/security.md) - [Extensibility](advanced/extensibility.md) - [Testing](advanced/testing.md) +- [Releasing](advanced/releases.md) diff --git a/docs/advanced/releases.md b/docs/advanced/releases.md new file mode 100644 index 00000000..9e4b7684 --- /dev/null +++ b/docs/advanced/releases.md @@ -0,0 +1,76 @@ +# Releasing IdLE + +This document describes how maintainers cut a release using the GitHub Actions workflow. + +## Prerequisites + +- You have write permissions to the repository. +- CI is green on `main`. +- The repository uses **immutable releases** (recommended). Once a release is published, its assets and tag should be treated as write-once. + +## Dry-run (no GitHub Release) + +Use this to validate the release packaging without creating a GitHub Release. + +1. Go to **GitHub → Actions → Release**. +2. Click **Run workflow**. +3. Provide a test tag (for example `v0.7.0-test`). +4. Leave **publish_release** unchecked / `false`. +5. When the workflow completes, download the ZIP from the **Artifacts** section of the run. + +This verifies: + +- deterministic packaging +- expected include/exclude rules +- the artifact can be produced on a clean runner + +## Cut a release (published GitHub Release) + +Releases are created from **annotated tags** matching `v*` (for example `v0.7.0`). + +From a clean working tree on `main`: + +```powershell +git checkout main +git pull --ff-only + +# Create an annotated tag (recommended) +git tag -a v0.7.0 -m "IdLE v0.7.0" + +# Push the tag to trigger the Release workflow +git push origin v0.7.0 +``` + +What happens next: + +1. The **Release** workflow runs on the tag. +2. A deterministic ZIP artifact is created. +3. A GitHub Release is created for the tag, with auto-generated release notes. +4. The ZIP is uploaded as a release asset. + +## Versioning and naming + +- Use `vMAJOR.MINOR.PATCH` tags (for example `v0.7.0`). +- Pre-releases are allowed (for example `v0.7.0-rc.1`). They should be tested via the dry-run path first. + +## Troubleshooting + +### The workflow failed but no artifact exists + +- Check the step **Verify artifact exists** in the workflow logs. +- Run the packaging script locally in list-only mode to inspect the file list: + +```powershell +pwsh -NoProfile -File ./tools/New-IdleReleaseArtifact.ps1 -Tag v0.7.0-test -ListOnly +``` + +### I want to “redo” a release + +With immutable releases enabled, treat published releases as immutable. + +Preferred approach: + +1. Fix the issue on `main`. +2. Cut a new version tag (for example `v0.7.1`). + +Avoid deleting and reusing tags. diff --git a/docs/index.md b/docs/index.md index 2f17b2c1..a2fb2e44 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,6 +42,7 @@ used between IdLE and its hosts. - [Security](advanced/security.md) - [Extensibility](advanced/extensibility.md) - [Testing](advanced/testing.md) +- [Releasing](advanced/releases.md) ## Repository links From 6db1f458b97e12f5c6e21e57de809a93f1fb5a1a Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:30:25 +0100 Subject: [PATCH 7/9] test: add contract tests for deterministic release artifact builder --- tests/New-IdleReleaseArtifact.Tests.ps1 | 149 ++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 tests/New-IdleReleaseArtifact.Tests.ps1 diff --git a/tests/New-IdleReleaseArtifact.Tests.ps1 b/tests/New-IdleReleaseArtifact.Tests.ps1 new file mode 100644 index 00000000..4ddda74d --- /dev/null +++ b/tests/New-IdleReleaseArtifact.Tests.ps1 @@ -0,0 +1,149 @@ +# Requires -Version 7.0 +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + + $script:RepoRoot = Get-RepoRootPath + $script:ReleaseScriptPath = Join-Path $script:RepoRoot 'tools/New-IdleReleaseArtifact.ps1' + + if (-not (Test-Path -LiteralPath $script:ReleaseScriptPath)) { + throw "Release artifact script not found: $script:ReleaseScriptPath" + } +} + +Describe 'New-IdleReleaseArtifact.ps1' { + + Context 'Tag validation' { + + It 'accepts a valid tag format' { + { & $script:ReleaseScriptPath -Tag 'v0.7.0-test' -ListOnly 6>&1 | Out-Null } | Should -Not -Throw + } + + It 'rejects tags without v-prefix' { + { & $script:ReleaseScriptPath -Tag '0.7.0' -ListOnly 6>&1 | Out-Null } | Should -Throw + } + + It 'rejects tags with path separators' { + { & $script:ReleaseScriptPath -Tag 'v0.7.0/evil' -ListOnly 6>&1 | Out-Null } | Should -Throw + } + + It 'rejects tags with whitespace' { + { & $script:ReleaseScriptPath -Tag 'v0.7.0 test' -ListOnly 6>&1 | Out-Null } | Should -Throw + } + } + + Context 'ListOnly output contract' { + + function Get-ListOnlyPaths { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Tag + ) + + # Write-Host writes to the Information stream in PowerShell 7. + # Merge Information stream to success output and parse lines starting with " - ". + $lines = & $script:ReleaseScriptPath -Tag $Tag -ListOnly 6>&1 | + ForEach-Object { $_.ToString() } + + $paths = $lines | + Where-Object { $_ -like ' - *' } | + ForEach-Object { $_.Substring(3) } + + return ,$paths + } + + It 'returns a deterministic file list (stable ordering)' { + $a = Get-ListOnlyPaths -Tag 'v0.7.0-test' + $b = Get-ListOnlyPaths -Tag 'v0.7.0-test' + + $a | Should -Not -BeNullOrEmpty + $b | Should -Not -BeNullOrEmpty + $a | Should -Be $b + } + + It 'does not include excluded top-level paths' { + $paths = Get-ListOnlyPaths -Tag 'v0.7.0-test' + + # Normalize to forward slashes for consistent assertions + $norm = $paths | ForEach-Object { $_ -replace '\\', '/' } + + ($norm | Where-Object { $_ -match '^tools/' }) | Should -BeNullOrEmpty + ($norm | Where-Object { $_ -match '^\.github/' }) | Should -BeNullOrEmpty + ($norm | Where-Object { $_ -match '^tests/' }) | Should -BeNullOrEmpty + ($norm | Where-Object { $_ -match '^artifacts/' }) | Should -BeNullOrEmpty + } + + It 'does not include common build output folders' { + $paths = Get-ListOnlyPaths -Tag 'v0.7.0-test' + + $norm = $paths | ForEach-Object { $_ -replace '\\', '/' } + + ($norm | Where-Object { $_ -match '(^|/)bin/' }) | Should -BeNullOrEmpty + ($norm | Where-Object { $_ -match '(^|/)obj/' }) | Should -BeNullOrEmpty + } + } + + Context 'ZIP creation contract' { + + It 'creates a ZIP artifact and excludes forbidden content' { + $tempDir = Join-Path $TestDrive 'artifacts' + New-Item -Path $tempDir -ItemType Directory -Force | Out-Null + + $tag = 'v0.7.0-test' + $zip = & $script:ReleaseScriptPath -Tag $tag -OutputDirectory $tempDir + $zip | Should -Not -BeNullOrEmpty + $zip.FullName | Should -Exist + + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $archive = [System.IO.Compression.ZipFile]::OpenRead($zip.FullName) + try { + $entries = $archive.Entries | ForEach-Object { $_.FullName } + + $entries | Should -Not -BeNullOrEmpty + + # Normalize to forward slashes, Zip uses them anyway + ($entries | Where-Object { $_ -match '^tools/' }) | Should -BeNullOrEmpty + ($entries | Where-Object { $_ -match '^\.github/' }) | Should -BeNullOrEmpty + ($entries | Where-Object { $_ -match '^tests/' }) | Should -BeNullOrEmpty + ($entries | Where-Object { $_ -match '^artifacts/' }) | Should -BeNullOrEmpty + + ($entries | Where-Object { $_ -match '(^|/)bin/' }) | Should -BeNullOrEmpty + ($entries | Where-Object { $_ -match '(^|/)obj/' }) | Should -BeNullOrEmpty + } + finally { + $archive.Dispose() + } + } + + It 'writes stable ZIP entry timestamps for deterministic artifacts' { + $tempDir = Join-Path $TestDrive 'artifacts' + New-Item -Path $tempDir -ItemType Directory -Force | Out-Null + + $tag = 'v0.7.0-test' + $zip = & $script:ReleaseScriptPath -Tag $tag -OutputDirectory $tempDir + + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + + $expected = [DateTimeOffset]::new(1980, 1, 1, 0, 0, 0, [TimeSpan]::Zero) + + $archive = [System.IO.Compression.ZipFile]::OpenRead($zip.FullName) + try { + # Sample a few entries (enough to catch regressions without being expensive) + $sample = $archive.Entries | Select-Object -First 10 + $sample | Should -Not -BeNullOrEmpty + + foreach ($e in $sample) { + $e.LastWriteTime | Should -Be $expected + } + } + finally { + $archive.Dispose() + } + } + } +} From cb7a1e3e24c098a9a0a8efb5a29f54a5bf10e5aa Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:40:26 +0100 Subject: [PATCH 8/9] fix: resolve test failures in New-IdleReleaseArtifact tests - Fix scoping issue with Get-ListOnlyPaths by converting function to BeforeEach block with script variable - Fix timestamp comparison in ZIP entry tests by comparing date only (1980-01-01) instead of full DateTimeOffset with timezone - All 9 tests now pass --- tests/New-IdleReleaseArtifact.Tests.ps1 | 51 ++++++++++++++----------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/tests/New-IdleReleaseArtifact.Tests.ps1 b/tests/New-IdleReleaseArtifact.Tests.ps1 index 4ddda74d..fe1462af 100644 --- a/tests/New-IdleReleaseArtifact.Tests.ps1 +++ b/tests/New-IdleReleaseArtifact.Tests.ps1 @@ -35,28 +35,29 @@ Describe 'New-IdleReleaseArtifact.ps1' { Context 'ListOnly output contract' { - function Get-ListOnlyPaths { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string] $Tag - ) - - # Write-Host writes to the Information stream in PowerShell 7. - # Merge Information stream to success output and parse lines starting with " - ". - $lines = & $script:ReleaseScriptPath -Tag $Tag -ListOnly 6>&1 | - ForEach-Object { $_.ToString() } - - $paths = $lines | - Where-Object { $_ -like ' - *' } | - ForEach-Object { $_.Substring(3) } - - return ,$paths + BeforeEach { + $script:GetListOnlyPaths = { + param( + [Parameter(Mandatory)] + [string] $Tag + ) + + # Write-Host writes to the Information stream in PowerShell 7. + # Merge Information stream to success output and parse lines starting with " - ". + $lines = & $script:ReleaseScriptPath -Tag $Tag -ListOnly 6>&1 | + ForEach-Object { $_.ToString() } + + $paths = $lines | + Where-Object { $_ -like ' - *' } | + ForEach-Object { $_.Substring(3) } + + return ,$paths + } } It 'returns a deterministic file list (stable ordering)' { - $a = Get-ListOnlyPaths -Tag 'v0.7.0-test' - $b = Get-ListOnlyPaths -Tag 'v0.7.0-test' + $a = & $script:GetListOnlyPaths -Tag 'v0.7.0-test' + $b = & $script:GetListOnlyPaths -Tag 'v0.7.0-test' $a | Should -Not -BeNullOrEmpty $b | Should -Not -BeNullOrEmpty @@ -64,7 +65,7 @@ Describe 'New-IdleReleaseArtifact.ps1' { } It 'does not include excluded top-level paths' { - $paths = Get-ListOnlyPaths -Tag 'v0.7.0-test' + $paths = & $script:GetListOnlyPaths -Tag 'v0.7.0-test' # Normalize to forward slashes for consistent assertions $norm = $paths | ForEach-Object { $_ -replace '\\', '/' } @@ -76,7 +77,7 @@ Describe 'New-IdleReleaseArtifact.ps1' { } It 'does not include common build output folders' { - $paths = Get-ListOnlyPaths -Tag 'v0.7.0-test' + $paths = & $script:GetListOnlyPaths -Tag 'v0.7.0-test' $norm = $paths | ForEach-Object { $_ -replace '\\', '/' } @@ -129,7 +130,9 @@ Describe 'New-IdleReleaseArtifact.ps1' { Add-Type -AssemblyName System.IO.Compression Add-Type -AssemblyName System.IO.Compression.FileSystem - $expected = [DateTimeOffset]::new(1980, 1, 1, 0, 0, 0, [TimeSpan]::Zero) + # ZIP timestamps are stored in DOS format. The date should be 1980-01-01. + # Due to timezone handling in ZipArchiveEntry, we check the date part only. + $expectedDate = [DateTime]::new(1980, 1, 1) $archive = [System.IO.Compression.ZipFile]::OpenRead($zip.FullName) try { @@ -138,7 +141,9 @@ Describe 'New-IdleReleaseArtifact.ps1' { $sample | Should -Not -BeNullOrEmpty foreach ($e in $sample) { - $e.LastWriteTime | Should -Be $expected + # LastWriteTime from ZipArchiveEntry returns DateTimeOffset. + # Check that the date is 1980-01-01 (ignore time and timezone). + $e.LastWriteTime.Date | Should -Be $expectedDate } } finally { From 7834512708a5376b1edb4e841b3424e45f9023a1 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:59:10 +0100 Subject: [PATCH 9/9] fix: checkout requested tag before packaging in release workflow When workflow_dispatch is called with explicit inputs.tag, the checkout step must reference that tag to ensure the ZIP is built from the correct commit. Previously, the workflow would checkout the default branch and then build from that code, while using the user-supplied tag in the artifact name. This caused a silent mismatch between the artifact content and its tag label. Fixes: P1 review comment - checkout must reference ref: steps.tag.outputs.value --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b5b3cd3..c98ae9b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,12 @@ jobs: "value=$refName" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + - name: Checkout requested tag + uses: actions/checkout@v4 + with: + ref: ${{ steps.tag.outputs.value }} + fetch-depth: 0 + - name: Build release artifact shell: pwsh run: |