diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..c98ae9b8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,93 @@ +name: Release + +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: Build artifact (and optionally publish 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: 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: Checkout requested tag + uses: actions/checkout@v4 + with: + ref: ${{ steps.tag.outputs.value }} + fetch-depth: 0 + + - name: Build release artifact + shell: pwsh + run: | + $tag = '${{ steps.tag.outputs.value }}' + ./tools/New-IdleReleaseArtifact.ps1 -Tag $tag + + - 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: ${{ steps.tag.outputs.value }} + name: ${{ steps.tag.outputs.value }} + generate_release_notes: true + files: | + artifacts/IdLE-${{ steps.tag.outputs.value }}.zip 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 diff --git a/tests/New-IdleReleaseArtifact.Tests.ps1 b/tests/New-IdleReleaseArtifact.Tests.ps1 new file mode 100644 index 00000000..fe1462af --- /dev/null +++ b/tests/New-IdleReleaseArtifact.Tests.ps1 @@ -0,0 +1,154 @@ +# 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' { + + 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 = & $script:GetListOnlyPaths -Tag 'v0.7.0-test' + $b = & $script:GetListOnlyPaths -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 = & $script:GetListOnlyPaths -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 = & $script:GetListOnlyPaths -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 + + # 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 { + # 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) { + # LastWriteTime from ZipArchiveEntry returns DateTimeOffset. + # Check that the date is 1980-01-01 (ignore time and timezone). + $e.LastWriteTime.Date | Should -Be $expectedDate + } + } + finally { + $archive.Dispose() + } + } + } +} diff --git a/tools/New-IdleReleaseArtifact.ps1 b/tools/New-IdleReleaseArtifact.ps1 new file mode 100644 index 00000000..dc5d90a9 --- /dev/null +++ b/tools/New-IdleReleaseArtifact.ps1 @@ -0,0 +1,278 @@ +<# +.SYNOPSIS + Creates a deterministic ZIP release artifact for IdLE. + +.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. + + This script is designed for CI usage (GitHub Actions) but can also be run locally. + +.PARAMETER Tag + The release tag used to name the artifact (e.g. v0.7.0). + +.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 ListOnly + If set, prints the normalized, deterministic file list and exits without creating a ZIP. + +.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)] + # 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] $RepoRootPath = (Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..')).Path, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $OutputDirectory, + + [Parameter()] + [switch] $ListOnly +) + +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 $RepoRootPath -ChildPath 'artifacts' +} + +# 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) + +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 +} + +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 '\\', '/') +} + +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()] + 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 -LiteralPath $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 normalized relative path for deterministic ordering. + $sorted = $files | + 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.FileInfo) +} + +function ConvertTo-IdleSafeFileName { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Value + ) + + # Make sure the file name is safe on all platforms. + # We already validated Tag, but keep a defensive normalization. + return ($Value -replace '[^\w\.\-\+]', '_') +} + +$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', + '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 +} + +$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). +if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force +} + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$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 $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) + $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