Skip to content
Merged
93 changes: 93 additions & 0 deletions .github/workflows/release.yml
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
Comment thread
blindzero marked this conversation as resolved.

- 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
1 change: 1 addition & 0 deletions docs/_navbar.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
- [Usage](usage/workflows.md)
- [Specifications](specs/plan-export.md)
- [Advanced](advanced/architecture.md)
- [Releasing](advanced/releases.md)
1 change: 1 addition & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@
- [Security](advanced/security.md)
- [Extensibility](advanced/extensibility.md)
- [Testing](advanced/testing.md)
- [Releasing](advanced/releases.md)
76 changes: 76 additions & 0 deletions docs/advanced/releases.md
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.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
154 changes: 154 additions & 0 deletions tests/New-IdleReleaseArtifact.Tests.ps1
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()
}
}
}
}
Loading