-
Notifications
You must be signed in to change notification settings - Fork 0
Release automation: deterministic artifacts + dry-run workflow + tests #59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
blindzero
merged 9 commits into
main
from
issues/13-Release-automation-tag---build-ZIP-artifact---GitHub-Release-notes
Jan 10, 2026
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
1ab9c14
release: add deterministic release artifact builder tool
blindzero 459e97d
ci: add GitHub Actions workflow for automated releases
blindzero dcd1656
ci: add workflow_dispatch dry-run mode for release workflow
blindzero 370fe1a
release: harden and add list-only mode to release artifact builder
blindzero a5d9e28
release: exclude tools from release artifact and include SECURITY.md
blindzero a01d4ab
docs: adding release documentation
blindzero 6db1f45
test: add contract tests for deterministic release artifact builder
blindzero cb7a1e3
fix: resolve test failures in New-IdleReleaseArtifact tests
blindzero 7834512
fix: checkout requested tag before packaging in release workflow
blindzero File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| } | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.