Skip to content

Release

Release #29

Workflow file for this run

name: Release
on:
schedule:
- cron: '0 13 * * 2'
workflow_dispatch:
inputs:
force:
description: Create a release even if no commits exist since the latest published release
required: false
default: false
type: boolean
draft:
description: Create the GitHub release as a draft
required: false
default: true
type: boolean
permissions:
contents: write
concurrency:
group: release
cancel-in-progress: false
jobs:
prepare-release:
name: Prepare Release
runs-on: ubuntu-slim
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
outputs:
should_release: ${{ steps.compute.outputs.should_release }}
release_tag: ${{ steps.compute.outputs.release_tag }}
release_name: ${{ steps.compute.outputs.release_name }}
release_version: ${{ steps.compute.outputs.release_version }}
velopack_version: ${{ steps.compute.outputs.velopack_version }}
draft_release: ${{ steps.compute.outputs.draft_release }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: Determine release metadata
id: compute
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
FORCE_RELEASE: ${{ inputs.force && 'true' || 'false' }}
DRAFT_RELEASE: ${{ inputs.draft && 'true' || 'false' }}
run: |
$ErrorActionPreference = 'Stop'
git fetch --force --tags
$latestReleaseTag = $null
try {
$latestReleaseTag = gh api "repos/$env:GITHUB_REPOSITORY/releases/latest" --jq '.tag_name'
}
catch {
Write-Host 'No published release found yet.'
}
$changedSourceFiles = if ([string]::IsNullOrWhiteSpace($latestReleaseTag)) {
@(git ls-files 'src')
}
else {
@(git diff --name-only "$latestReleaseTag..HEAD" -- src)
}
$sourceChangeCount = $changedSourceFiles.Count
$parisNow = [System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId([DateTimeOffset]::UtcNow, 'Europe/Paris')
$datePrefix = 'v{0}.{1}.{2}' -f ($parisNow.Year % 100), $parisNow.Month, $parisNow.Day
$existingTags = @(git tag -l "$datePrefix.*")
$nextBuild = 1
if ($existingTags.Count -gt 0) {
$existingBuilds = $existingTags |
ForEach-Object {
if ($_ -match '^v\d{2}\.\d{1,2}\.\d{1,2}\.(\d+)$') {
[int]$Matches[1]
}
} |
Sort-Object -Descending
if ($existingBuilds.Count -gt 0) {
$nextBuild = $existingBuilds[0] + 1
}
}
$releaseTag = "$datePrefix.$nextBuild"
$releaseVersion = $releaseTag.Substring(1)
$versionParts = $releaseVersion.Split('.')
$velopackVersion = '{0}.{1}.{2}-build.{3}' -f $versionParts[0], $versionParts[1], $versionParts[2], $versionParts[3]
$releaseName = "Foundry $releaseTag"
$isManualDispatch = $env:GITHUB_EVENT_NAME -eq 'workflow_dispatch'
$shouldRelease = $sourceChangeCount -gt 0 -or $env:FORCE_RELEASE -eq 'true' -or $isManualDispatch
"should_release=$($shouldRelease.ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT
"release_tag=$releaseTag" >> $env:GITHUB_OUTPUT
"release_name=$releaseName" >> $env:GITHUB_OUTPUT
"release_version=$releaseVersion" >> $env:GITHUB_OUTPUT
"velopack_version=$velopackVersion" >> $env:GITHUB_OUTPUT
"draft_release=$env:DRAFT_RELEASE" >> $env:GITHUB_OUTPUT
if ($shouldRelease) {
Write-Host "Release will be created with tag '$releaseTag'."
Write-Host "Velopack package version will be '$velopackVersion'."
Write-Host "Detected $sourceChangeCount source file change(s) since the latest published release."
}
else {
Write-Host "No source changes found since '$latestReleaseTag'. Skipping release creation."
}
- name: Create GitHub release
if: steps.compute.outputs.should_release == 'true'
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
$releaseArgs = @(
'${{ steps.compute.outputs.release_tag }}',
'--title', '${{ steps.compute.outputs.release_name }}',
'--target', '${{ github.sha }}',
'--generate-notes'
)
if ('${{ steps.compute.outputs.draft_release }}' -eq 'true') {
$releaseArgs += '--draft'
}
gh release create @releaseArgs
publish-release-assets:
name: Publish Release Assets
runs-on: windows-latest
needs: prepare-release
if: needs.prepare-release.outputs.should_release == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up .NET SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- name: Cache NuGet packages
uses: actions/cache@v5
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-${{ runner.arch }}-nuget-${{ hashFiles('src/**/*.csproj', 'src/Directory.Build.props', 'global.json', 'NuGet.config', 'nuget.config') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-nuget-
- name: Validate release tag and apply version
shell: pwsh
run: |
$tag = '${{ needs.prepare-release.outputs.release_tag }}'
$match = [regex]::Match($tag, '^v(?<year>\d{2})\.(?<month>\d{1,2})\.(?<day>\d{1,2})\.(?<build>[1-9]\d*)$')
if (-not $match.Success) {
throw "Release tag '$tag' must use the format vYY.M.D.Build."
}
$year = 2000 + [int]$match.Groups['year'].Value
$month = [int]$match.Groups['month'].Value
$day = [int]$match.Groups['day'].Value
try {
[void][datetime]::new($year, $month, $day, 0, 0, 0, [DateTimeKind]::Utc)
}
catch {
throw "Release tag '$tag' does not contain a valid calendar date."
}
$version = '${{ needs.prepare-release.outputs.release_version }}'
$velopackVersion = '${{ needs.prepare-release.outputs.velopack_version }}'
$expectedVelopackVersion = '{0}.{1}.{2}-build.{3}' -f $match.Groups['year'].Value, $match.Groups['month'].Value, $match.Groups['day'].Value, $match.Groups['build'].Value
if ($velopackVersion -ne $expectedVelopackVersion) {
throw "Velopack version '$velopackVersion' does not match expected value '$expectedVelopackVersion'."
}
$propsPath = Join-Path $env:GITHUB_WORKSPACE 'src\Directory.Build.props'
$content = Get-Content -Path $propsPath -Raw
foreach ($propertyName in @('Version', 'AssemblyVersion', 'FileVersion', 'InformationalVersion')) {
$pattern = "(?<=<$propertyName>)[^<]+(?=</$propertyName>)"
if (-not [regex]::IsMatch($content, $pattern)) {
throw "Unable to locate <$propertyName> in '$propsPath'."
}
$content = [regex]::Replace($content, $pattern, $version)
}
Set-Content -Path $propsPath -Value $content -NoNewline
Add-Content -Path $env:GITHUB_ENV -Value "RELEASE_VERSION=$version"
Add-Content -Path $env:GITHUB_ENV -Value "VELOPACK_VERSION=$velopackVersion"
- name: Prepare release notes
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
$releaseTag = '${{ needs.prepare-release.outputs.release_tag }}'
$releaseNotesPath = Join-Path $env:GITHUB_WORKSPACE 'artifacts\release-notes.md'
New-Item -Path (Split-Path -Parent $releaseNotesPath) -ItemType Directory -Force | Out-Null
$body = gh release view $releaseTag --json body --jq '.body'
if ([string]::IsNullOrWhiteSpace($body)) {
$body = "No release notes were generated."
}
Set-Content -Path $releaseNotesPath -Value "# Foundry OSD $releaseTag`n`n$body" -NoNewline
Add-Content -Path $env:GITHUB_ENV -Value "RELEASE_NOTES_PATH=$releaseNotesPath"
- name: Build release assets
shell: pwsh
run: |
$workspace = $env:GITHUB_WORKSPACE
$releaseRoot = Join-Path $workspace 'artifacts\release'
$publishRoot = Join-Path $releaseRoot 'publish'
$connectProject = Join-Path $workspace 'src\Foundry.Connect\Foundry.Connect.csproj'
$deployProject = Join-Path $workspace 'src\Foundry.Deploy\Foundry.Deploy.csproj'
$velopackScript = Join-Path $workspace 'scripts\Publish-FoundryVelopack.ps1'
$publishProperties = @(
'PublishSingleFile=true',
'EnableCompressionInSingleFile=true',
'IncludeNativeLibrariesForSelfExtract=true',
'IncludeAllContentForSelfExtract=true',
'DebugType=None',
'GenerateDocumentationFile=false'
)
function Invoke-Publish {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectPath,
[Parameter(Mandatory = $true)]
[string]$RuntimeIdentifier,
[Parameter(Mandatory = $true)]
[string]$Platform,
[Parameter(Mandatory = $true)]
[string]$OutputPath
)
if (Test-Path -Path $OutputPath) {
Remove-Item -Path $OutputPath -Recurse -Force
}
New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null
$publishArgs = @(
'publish',
$ProjectPath,
'-c', 'Release',
'-r', $RuntimeIdentifier,
'--self-contained', 'true',
'-o', $OutputPath,
'--nologo',
"-p:Platform=$Platform"
)
foreach ($property in $publishProperties) {
$publishArgs += "-p:$property"
}
dotnet @publishArgs
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed for '$ProjectPath' ($RuntimeIdentifier)."
}
}
if (Test-Path -Path $releaseRoot) {
Remove-Item -Path $releaseRoot -Recurse -Force
}
New-Item -Path $releaseRoot -ItemType Directory -Force | Out-Null
New-Item -Path $publishRoot -ItemType Directory -Force | Out-Null
foreach ($runtimeIdentifier in @('win-x64', 'win-arm64')) {
$platform = if ($runtimeIdentifier -eq 'win-x64') { 'x64' } else { 'ARM64' }
powershell -ExecutionPolicy Bypass -File $velopackScript `
-RuntimeIdentifier $runtimeIdentifier `
-PackVersion $env:VELOPACK_VERSION `
-ReleaseNotesPath $env:RELEASE_NOTES_PATH
if ($LASTEXITCODE -ne 0) {
throw "Velopack packaging failed for Foundry ($runtimeIdentifier)."
}
$deployPublishPath = Join-Path $publishRoot (Join-Path 'Foundry.Deploy' $runtimeIdentifier)
Invoke-Publish -ProjectPath $deployProject -RuntimeIdentifier $runtimeIdentifier -Platform $platform -OutputPath $deployPublishPath
$deployExecutable = Join-Path $deployPublishPath 'Foundry.Deploy.exe'
if (-not (Test-Path -Path $deployExecutable -PathType Leaf)) {
throw "Expected Foundry.Deploy executable not found: '$deployExecutable'."
}
$deployAssetPath = Join-Path $releaseRoot "Foundry.Deploy-$runtimeIdentifier.zip"
Compress-Archive -Path (Join-Path $deployPublishPath '*') -DestinationPath $deployAssetPath -CompressionLevel Optimal -Force
$connectPublishPath = Join-Path $publishRoot (Join-Path 'Foundry.Connect' $runtimeIdentifier)
Invoke-Publish -ProjectPath $connectProject -RuntimeIdentifier $runtimeIdentifier -Platform $platform -OutputPath $connectPublishPath
$connectExecutable = Join-Path $connectPublishPath 'Foundry.Connect.exe'
if (-not (Test-Path -Path $connectExecutable -PathType Leaf)) {
throw "Expected Foundry.Connect executable not found: '$connectExecutable'."
}
$connectAssetPath = Join-Path $releaseRoot "Foundry.Connect-$runtimeIdentifier.zip"
Compress-Archive -Path (Join-Path $connectPublishPath '*') -DestinationPath $connectAssetPath -CompressionLevel Optimal -Force
}
- name: Upload release assets
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
$releaseTag = '${{ needs.prepare-release.outputs.release_tag }}'
$releaseName = '${{ needs.prepare-release.outputs.release_name }}'
$releaseRoot = Join-Path $env:GITHUB_WORKSPACE 'artifacts\release'
$velopackRoot = Join-Path $env:GITHUB_WORKSPACE 'artifacts\velopack'
$vpkExecutable = Join-Path $env:GITHUB_WORKSPACE 'artifacts\tools\vpk\vpk.exe'
if (-not (Test-Path -Path $vpkExecutable -PathType Leaf)) {
throw "Velopack CLI was not installed: '$vpkExecutable'."
}
foreach ($runtimeIdentifier in @('win-x64', 'win-arm64')) {
$velopackReleasePath = Join-Path $velopackRoot (Join-Path 'releases' $runtimeIdentifier)
if (-not (Test-Path -Path $velopackReleasePath -PathType Container)) {
throw "Velopack release output was not created: '$velopackReleasePath'."
}
& $vpkExecutable upload github `
--outputDir $velopackReleasePath `
--channel $runtimeIdentifier `
--repoUrl "https://github.com/${{ github.repository }}" `
--token $env:GH_TOKEN `
--merge true `
--releaseName $releaseName `
--tag $releaseTag
if ($LASTEXITCODE -ne 0) {
throw "Velopack GitHub upload failed for '$runtimeIdentifier'."
}
$setupAssetNames = gh release view $releaseTag --json assets --jq '.assets[].name' |
Where-Object { $_ -like "*-$runtimeIdentifier-Setup.exe" }
if ($LASTEXITCODE -ne 0) {
throw "Unable to inspect GitHub release assets for '$releaseTag'."
}
foreach ($setupAssetName in $setupAssetNames) {
gh release delete-asset $releaseTag $setupAssetName --yes
if ($LASTEXITCODE -ne 0) {
throw "Unable to delete GitHub release asset '$setupAssetName'."
}
}
}
$assets = @(
(Join-Path $releaseRoot 'Foundry.Connect-win-x64.zip'),
(Join-Path $releaseRoot 'Foundry.Connect-win-arm64.zip'),
(Join-Path $releaseRoot 'Foundry.Deploy-win-x64.zip'),
(Join-Path $releaseRoot 'Foundry.Deploy-win-arm64.zip')
)
foreach ($asset in $assets) {
if (-not (Test-Path -Path $asset -PathType Leaf)) {
throw "Release asset was not created: '$asset'."
}
}
gh release upload $releaseTag @assets --clobber