Skip to content

Merge pull request #147 from blindzero/release/v0.9.2 #22

Merge pull request #147 from blindzero/release/v0.9.2

Merge pull request #147 from blindzero/release/v0.9.2 #22

Workflow file for this run

name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Artifact tag name (e.g. v0.7.0-test). This does NOT need to exist as a git tag.'
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
publish_psgallery:
description: 'If true, publish the module to PowerShell Gallery (requires explicit intent).'
required: false
default: false
type: boolean
permissions:
contents: write
actions: read
jobs:
release:
name: Build artifact (and optionally publish release)
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.value }}
is_prerelease: ${{ steps.semver.outputs.is_prerelease }}
is_stable: ${{ steps.semver.outputs.is_stable }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Show PowerShell version
shell: pwsh
run: |
$PSVersionTable.PSVersion
pwsh -v
- name: Determine artifact 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: Classify tag (stable vs prerelease)
id: semver
shell: pwsh
run: |
$tag = '${{ steps.tag.outputs.value }}'
$isStable = $tag -match '^v\d+\.\d+\.\d+$'
$isPrerelease = $tag -match '^v\d+\.\d+\.\d+-[0-9A-Za-z][0-9A-Za-z\.\-]*$'
"is_stable=$($isStable.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
"is_prerelease=$($isPrerelease.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
- name: Require tag to point to main HEAD
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
git fetch origin main --depth 1 | Out-Null
$tagSha = (git rev-parse '${{ github.ref }}^{}').Trim()
$mainSha = (git rev-parse 'origin/main').Trim()
if ($tagSha -ne $mainSha) {
throw "Tag does not point to origin/main HEAD. Tag SHA=$tagSha, main SHA=$mainSha"
}
Write-Host "Tag points to origin/main HEAD ($mainSha)."
- name: Require green CI on the target commit
if: ${{ inputs.publish_release == true || inputs.publish_psgallery == true || startsWith(github.ref, 'refs/tags/v') }}
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repo = '${{ github.repository }}'
$sha = '${{ github.sha }}'
# NOTE: This must match the workflow filename for CI.
# In this repository it is: .github/workflows/ci.yml
$uri = "https://api.github.com/repos/$repo/actions/workflows/ci.yml/runs?per_page=100&head_sha=$sha"
$headers = @{
Authorization = "Bearer $env:GH_TOKEN"
Accept = "application/vnd.github+json"
"X-GitHub-Api-Version" = "2022-11-28"
}
$resp = Invoke-RestMethod -Method Get -Uri $uri -Headers $headers
$run = $resp.workflow_runs | Sort-Object run_number -Descending | Select-Object -First 1
if (-not $run) {
throw "No completed CI workflow run found for SHA $sha. If CI is currently running for this commit, wait for it to complete successfully on main before releasing."
}
if ($run.status -ne 'completed' -or $run.conclusion -ne 'success') {
throw "CI is not green for SHA $sha. Status=$($run.status), Conclusion=$($run.conclusion)."
}
Write-Host "CI is green for SHA $sha (run #$($run.run_number))."
- name: Validate tag version matches module manifest version(s)
shell: pwsh
run: |
$tag = '${{ steps.tag.outputs.value }}'
if ($tag -notmatch '^v(\d+\.\d+\.\d+)(?:-[0-9A-Za-z][0-9A-Za-z\.\-]*)?$') {
Write-Host "Tag '$tag' is not SemVer-like. Skipping module version validation."
exit 0
}
$expectedVersion = $Matches[1]
Write-Host "Expected module version from tag: $expectedVersion"
$manifestPaths = @(
'src/IdLE/IdLE.psd1',
'src/IdLE.Core/IdLE.Core.psd1',
'src/IdLE.Steps.Common/IdLE.Steps.Common.psd1',
'src/IdLE.Steps.DirectorySync/IdLE.Steps.DirectorySync.psd1',
'src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1',
'src/IdLE.Provider.AD/IdLE.Provider.AD.psd1',
'src/IdLE.Provider.EntraID/IdLE.Provider.EntraID.psd1',
'src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psd1',
'src/IdLE.Provider.DirectorySync.EntraConnect/IdLE.Provider.DirectorySync.EntraConnect.psd1',
'src/IdLE.Provider.Mock/IdLE.Provider.Mock.psd1'
)
$mismatches = @()
foreach ($path in $manifestPaths) {
if (-not (Test-Path -LiteralPath $path)) {
throw "Module manifest not found: $path"
}
$data = Import-PowerShellDataFile -LiteralPath $path
$actualVersion = [string]$data.ModuleVersion
if ($actualVersion -ne $expectedVersion) {
$mismatches += [pscustomobject]@{
Path = $path
Expected = $expectedVersion
ModuleVersion = $actualVersion
}
}
}
if ($mismatches.Count -gt 0) {
$mismatches | Format-Table -AutoSize | Out-String | Write-Host
throw "Tag '$tag' does not match module manifest version(s). Run the version bump before tagging."
}
Write-Host "Manifest versions match tag version ($expectedVersion)."
- 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: Expand artifact for dry-run upload
if: ${{ inputs.publish_release != true && !startsWith(github.ref, 'refs/tags/v') }}
shell: pwsh
run: |
$tag = '${{ steps.tag.outputs.value }}'
$zipPath = "artifacts/IdLE-$tag.zip"
$staging = "artifacts/staging-$tag"
if (Test-Path -LiteralPath $staging) {
Remove-Item -LiteralPath $staging -Recurse -Force
}
New-Item -ItemType Directory -Path $staging -Force | Out-Null
Expand-Archive -Path $zipPath -DestinationPath $staging -Force
Get-ChildItem -LiteralPath $staging -Recurse | Select-Object FullName | Format-List
- name: Upload workflow artifact (expanded folder)
if: ${{ inputs.publish_release != true && !startsWith(github.ref, 'refs/tags/v') }}
uses: actions/upload-artifact@v6
with:
name: IdLE-${{ steps.tag.outputs.value }}-expanded
path: artifacts/staging-${{ steps.tag.outputs.value }}
if-no-files-found: error
- name: Upload workflow artifact (zip)
if: ${{ inputs.publish_release == true || startsWith(github.ref, 'refs/tags/v') }}
uses: actions/upload-artifact@v6
with:
name: IdLE-${{ steps.tag.outputs.value }}-zip
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
prerelease: ${{ steps.semver.outputs.is_prerelease == 'true' }}
files: |
artifacts/IdLE-${{ steps.tag.outputs.value }}.zip
psgallery_local_test:
name: Test module publish (local repository)
runs-on: ubuntu-latest
needs: release
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Show PowerShell version
shell: pwsh
run: |
$PSVersionTable.PSVersion
pwsh -v
- name: Install PowerShellGet
shell: pwsh
run: |
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted
Install-Module -Name PowerShellGet -Scope CurrentUser -Force -AllowClobber
Import-Module PowerShellGet -Force
Get-Module PowerShellGet | Select-Object Name, Version, Path | Format-List
- name: Build publishable module packages (multi-module for PSGallery)
shell: pwsh
run: |
./tools/New-IdleModulePackage.ps1 -Mode MultiModule -Clean | Format-List FullName
- name: Publish modules to local PSRepository and verify install/import
shell: pwsh
run: |
$modulesPath = Join-Path $env:GITHUB_WORKSPACE 'artifacts/modules'
if (-not (Test-Path -LiteralPath $modulesPath)) {
throw "Staged modules path not found: $modulesPath"
}
# Ensure required modules are discoverable during Test-ModuleManifest/Publish-Module.
# Save original PSModulePath to restore before install/import verification.
$originalPSModulePath = $env:PSModulePath
$pathSeparator = [System.IO.Path]::PathSeparator
$modulePathEntries = $env:PSModulePath -split [regex]::Escape($pathSeparator)
if ($modulePathEntries -notcontains $modulesPath) {
$env:PSModulePath = $modulesPath + $pathSeparator + $env:PSModulePath
}
$repoPath = Join-Path $env:RUNNER_TEMP 'psrepo'
New-Item -ItemType Directory -Path $repoPath -Force | Out-Null
Register-PSRepository -Name 'LocalRepo' -SourceLocation $repoPath -PublishLocation $repoPath -InstallationPolicy Trusted
# Load publish order from configuration
$configPath = Join-Path $env:GITHUB_WORKSPACE 'tools/ModulePublishOrder.psd1'
$config = Import-PowerShellDataFile -LiteralPath $configPath
$publishOrder = $config.PublishOrder
Write-Host "Publishing modules in dependency order from configuration..."
foreach ($moduleName in $publishOrder) {
$modulePath = Join-Path $modulesPath $moduleName
if (-not (Test-Path -LiteralPath $modulePath)) {
throw "Module listed in ModulePublishOrder.psd1 not found: $moduleName at $modulePath. This indicates an incomplete build and must be fixed before release."
}
Write-Host "Publishing $moduleName..."
Publish-Module -Path $modulePath -Repository 'LocalRepo' -ErrorAction Stop
Write-Host " ✓ Published $moduleName"
}
# Restore original PSModulePath before install/import verification
# to ensure Import-Module resolves the installed package, not the staged build.
$env:PSModulePath = $originalPSModulePath
# Test that IdLE can be installed and imports correctly
Write-Host "`nInstalling IdLE from LocalRepo..."
Install-Module -Name 'IdLE' -Repository 'LocalRepo' -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
Import-Module IdLE -Force -ErrorAction Stop
$m = Get-Module IdLE
if (-not $m) { throw 'IdLE did not import successfully.' }
Write-Host "Imported IdLE: $($m.Name) $($m.Version)"
# Verify dependencies were installed
$core = Get-Module IdLE.Core
$steps = Get-Module IdLE.Steps.Common
if (-not $core) { throw 'IdLE.Core was not loaded as dependency.' }
if (-not $steps) { throw 'IdLE.Steps.Common was not loaded as dependency.' }
Write-Host "Dependencies loaded: IdLE.Core $($core.Version), IdLE.Steps.Common $($steps.Version)"
Unregister-PSRepository -Name 'LocalRepo' -ErrorAction SilentlyContinue
psgallery:
name: Publish to PowerShell Gallery
runs-on: ubuntu-latest
needs: [release, psgallery_local_test]
environment: psgallery-prod
if: >-
${{
needs.release.outputs.is_stable == 'true'
&& (
startsWith(github.ref, 'refs/tags/v')
|| (github.event_name == 'workflow_dispatch' && inputs.publish_psgallery == true && inputs.tag != '')
)
}}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Show PowerShell version
shell: pwsh
run: |
$PSVersionTable.PSVersion
pwsh -v
- name: Install PowerShellGet
shell: pwsh
run: |
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted
Install-Module -Name PowerShellGet -Scope CurrentUser -Force -AllowClobber
Import-Module PowerShellGet -Force
Get-Module PowerShellGet | Select-Object Name, Version, Path | Format-List
- name: Build publishable module packages (multi-module for PSGallery)
shell: pwsh
run: |
./tools/New-IdleModulePackage.ps1 -Mode MultiModule -Clean | Format-List FullName
- name: Publish modules to PSGallery
shell: pwsh
env:
PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }}
run: |
if ([string]::IsNullOrWhiteSpace($env:PSGALLERY_API_KEY)) {
throw "Missing secret PSGALLERY_API_KEY."
}
$modulesPath = Join-Path $env:GITHUB_WORKSPACE 'artifacts/modules'
if (-not (Test-Path -LiteralPath $modulesPath)) {
throw "Staged modules path not found: $modulesPath"
}
# Ensure required modules are discoverable during Test-ModuleManifest/Publish-Module.
$pathSeparator = [System.IO.Path]::PathSeparator
$modulePathEntries = $env:PSModulePath -split [regex]::Escape($pathSeparator)
if ($modulePathEntries -notcontains $modulesPath) {
$env:PSModulePath = $modulesPath + $pathSeparator + $env:PSModulePath
}
# Load publish order from configuration
$configPath = Join-Path $env:GITHUB_WORKSPACE 'tools/ModulePublishOrder.psd1'
$config = Import-PowerShellDataFile -LiteralPath $configPath
$publishOrder = $config.PublishOrder
Write-Host "Publishing modules to PowerShell Gallery in dependency order..."
Write-Host "==================================================================`n"
foreach ($moduleName in $publishOrder) {
$modulePath = Join-Path $modulesPath $moduleName
if (-not (Test-Path -LiteralPath $modulePath)) {
throw "Module listed in ModulePublishOrder.psd1 not found: $moduleName at $modulePath. This indicates an incomplete build and must be fixed before publishing to PSGallery."
}
$manifest = Join-Path $modulePath "$moduleName.psd1"
if (-not (Test-Path -LiteralPath $manifest)) {
throw "Module manifest not found: $manifest"
}
$data = Import-PowerShellDataFile -LiteralPath $manifest
Write-Host "Publishing: $moduleName Version: $($data.ModuleVersion)"
try {
Publish-Module -Path $modulePath -NuGetApiKey $env:PSGALLERY_API_KEY -Repository PSGallery -ErrorAction Stop
Write-Host " ✓ Successfully published $moduleName`n"
# Brief delay to allow PSGallery to process the module before publishing dependents
Start-Sleep -Seconds 30
}
catch {
Write-Error "Failed to publish $moduleName : $_"
throw
}
}
Write-Host "==================================================================`n"
Write-Host "All modules published successfully!"