From 8df2cdf9effce015e759d9bf7e9c205f6a5a0796 Mon Sep 17 00:00:00 2001 From: Ben Martens Date: Mon, 2 Nov 2026 09:16:31 -0800 Subject: [PATCH 1/2] Automate version-stamped draft releases Recover the release workflow and build metadata stamping, aligned with current SDK and contributor guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/Release.ps1 | 162 ++++++++++++++ .github/scripts/Test-Release.ps1 | 240 +++++++++++++++++++++ .github/workflows/pr.yml | 4 + .github/workflows/release.yml | 122 +++++++++++ CONTRIBUTING.md | 28 ++- README.md | 2 + SyncKusto.Tests/Forms/BuildVersionTests.cs | 48 +++++ SyncKusto/MainForm.Designer.cs | 3 +- SyncKusto/MainForm.cs | 7 + SyncKusto/SyncKusto.csproj | 20 +- 10 files changed, 632 insertions(+), 4 deletions(-) create mode 100644 .github/scripts/Release.ps1 create mode 100644 .github/scripts/Test-Release.ps1 create mode 100644 .github/workflows/release.yml create mode 100644 SyncKusto.Tests/Forms/BuildVersionTests.cs diff --git a/.github/scripts/Release.ps1 b/.github/scripts/Release.ps1 new file mode 100644 index 0000000..06cb8a2 --- /dev/null +++ b/.github/scripts/Release.ps1 @@ -0,0 +1,162 @@ +Set-StrictMode -Version Latest + +function Get-ReleaseVersion { + param( + [Parameter(Mandatory)][string] $Date, + [AllowEmptyCollection()][string[]] $ExistingTags = @() + ) + + $parsedDate = [datetime]::MinValue + if ($Date -cnotmatch '^[0-9]{8}$' -or + -not [datetime]::TryParseExact($Date, 'yyyyMMdd', [cultureinfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::None, [ref] $parsedDate)) { + throw "Release date must be a real calendar date in YYYYMMDD format: $Date" + } + + $highest = -1 + foreach ($tag in $ExistingTags) { + if ($tag -cmatch "^v$Date(?:\.([1-9][0-9]*))?$") { + $suffix = 0 + if ($Matches[1] -and -not [int]::TryParse($Matches[1], [ref] $suffix)) { + throw "Release suffix exceeds the supported integer range: $tag" + } + $highest = [Math]::Max($highest, $suffix) + } + } + if ($highest -eq [int]::MaxValue) { + throw "Release suffixes are exhausted for $Date" + } + if ($highest -lt 0) { + return $Date + } + return "$Date.$($highest + 1)" +} + +function Invoke-ReleaseGitHub { + param([Parameter(Mandatory)][string[]] $Arguments) + + $output = & gh @Arguments + if ($LASTEXITCODE -ne 0) { + throw "GitHub CLI '$($Arguments[0])' failed with exit code $LASTEXITCODE." + } + if ([string]::IsNullOrWhiteSpace(($output -join "`n"))) { + throw "GitHub CLI '$($Arguments[0])' returned an empty response." + } + return ($output -join "`n") +} + +function Get-ReservedReleaseTags { + param([Parameter(Mandatory)][string] $Repository) + + foreach ($collection in @('releases', 'tags')) { + $pages = Invoke-ReleaseGitHub @('api', "repos/$Repository/${collection}?per_page=100", '--paginate', '--slurp') | + ConvertFrom-Json -Depth 100 -NoEnumerate -ErrorAction Stop + if ($pages -isnot [array]) { + throw "GitHub API returned an invalid $collection response: expected an array of pages." + } + foreach ($page in $pages) { + if ($page -isnot [array]) { + throw "GitHub API returned an invalid $collection page: expected an array." + } + foreach ($entry in $page) { + $name = if ($collection -eq 'releases') { $entry.tag_name } else { $entry.name } + if ([string]::IsNullOrWhiteSpace($name)) { + throw "GitHub API returned an invalid $collection entry with no tag name." + } + $name + } + } + } +} + +function New-ReleaseArchive { + param( + [Parameter(Mandatory)][string] $PublishDirectory, + [Parameter(Mandatory)][string] $TestedAssembly, + [Parameter(Mandatory)][string] $Destination + ) + + $requiredFiles = @( + 'SyncKusto.exe', 'SyncKusto.dll', 'SyncKusto.deps.json', 'SyncKusto.runtimeconfig.json', + 'SyncKusto.Core.dll', 'SyncKusto.Kusto.dll', 'SyncKusto.FileSystem.dll' + ) + foreach ($name in $requiredFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $PublishDirectory $name) -PathType Leaf)) { + throw "Published application is missing $name" + } + } + if (@(Get-ChildItem -LiteralPath $PublishDirectory -Recurse -Filter '*.Tests.dll').Count -ne 0) { + throw 'Published application must not contain test assemblies.' + } + $expectedHash = (Get-FileHash -LiteralPath $TestedAssembly -Algorithm SHA256).Hash + if ((Get-FileHash -LiteralPath (Join-Path $PublishDirectory 'SyncKusto.dll') -Algorithm SHA256).Hash -cne $expectedHash) { + throw 'Published application differs from the version-stamped assembly that passed the tests.' + } + $destinationPath = [IO.Path]::GetFullPath($Destination) + $null = [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destinationPath)) + [IO.Compression.ZipFile]::CreateFromDirectory($PublishDirectory, $destinationPath) + + $archive = [IO.Compression.ZipFile]::OpenRead($destinationPath) + try { + foreach ($name in $requiredFiles) { + if ($null -eq $archive.GetEntry($name)) { + throw "Release ZIP is missing $name at its root." + } + } + $stream = $archive.GetEntry('SyncKusto.dll').Open() + try { + $packagedHash = [Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($stream)) + if ($packagedHash -cne $expectedHash) { + throw 'Release ZIP does not contain the tested application assembly.' + } + } + finally { + $stream.Dispose() + } + } + finally { + $archive.Dispose() + } +} + +function New-DraftRelease { + param( + [Parameter(Mandatory)][string] $Repository, + [Parameter(Mandatory)][ValidatePattern('^[0-9]{8}(\.[1-9][0-9]*)?$')][string] $Version, + [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string] $Commit, + [Parameter(Mandatory)][string] $Archive + ) + + $null = Get-ReleaseVersion -Date $Version.Substring(0, 8) + $file = Get-Item -LiteralPath $Archive -ErrorAction Stop + if ($file.Name -cne 'synckusto.zip' -or $file.Length -eq 0) { + throw 'The release asset must be a nonempty synckusto.zip.' + } + $tag = "v$Version" + if ($tag -cin @(Get-ReservedReleaseTags -Repository $Repository)) { + throw "Release number is already reserved: $tag. Nothing will be overwritten." + } + + # Atomically reserve the tag so even a concurrent manual release cannot reuse it. + $null = Invoke-ReleaseGitHub @('api', '--method', 'POST', "repos/$Repository/git/refs", + '-f', "ref=refs/tags/$tag", '-f', "sha=$Commit") + $null = Invoke-ReleaseGitHub @('release', 'create', $tag, $file.FullName, '--repo', $Repository, + '--verify-tag', '--target', $Commit, '--draft', '--title', "SyncKusto $tag", + '--generate-notes', '--notes', "Source commit: $Commit") + + $release = Invoke-ReleaseGitHub @('release', 'view', $tag, '--repo', $Repository, + '--json', 'isDraft,tagName,targetCommitish,assets,url') | ConvertFrom-Json -ErrorAction Stop + $reference = Invoke-ReleaseGitHub @('api', "repos/$Repository/git/ref/tags/$tag") | + ConvertFrom-Json -ErrorAction Stop + $assets = @($release.assets | Where-Object { $_.name -ceq $file.Name }) + $digest = 'sha256:' + (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + if (-not $release.isDraft -or $release.tagName -cne $tag -or $release.targetCommitish -cne $Commit -or + $reference.object.type -cne 'commit' -or $reference.object.sha -cne $Commit) { + throw 'Created release is not a draft targeting the tested source commit.' + } + if ($assets.Count -ne 1 -or $assets[0].state -cne 'uploaded' -or + $assets[0].size -ne $file.Length -or $assets[0].digest -cne $digest) { + throw 'Draft release asset verification failed. Inspect the incomplete draft before publishing.' + } + return $release +} diff --git a/.github/scripts/Test-Release.ps1 b/.github/scripts/Test-Release.ps1 new file mode 100644 index 0000000..e867c10 --- /dev/null +++ b/.github/scripts/Test-Release.ps1 @@ -0,0 +1,240 @@ +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot\Release.ps1" + +$passed = 0 +function Assert-Equal($Actual, $Expected) { + if ($Actual -cne $Expected) { + throw "Expected '$Expected', got '$Actual'." + } +} +function Assert-Fails([scriptblock] $Action, [string] $Message) { + $failure = $null + try { & $Action } + catch { $failure = $_ } + if ($null -eq $failure -or $failure.Exception.Message -notlike "*$Message*") { + throw "Expected failure containing '$Message', got '$failure'." + } +} +function Test-Case([string] $Name, [scriptblock] $Action) { + & $Action + $script:passed++ + Write-Host "PASS $Name" +} + +$fixture = Join-Path ([IO.Path]::GetTempPath()) "synckusto-release-$([guid]::NewGuid().ToString('N'))" +$null = [IO.Directory]::CreateDirectory($fixture) +$publish = Join-Path $fixture 'publish' +$null = [IO.Directory]::CreateDirectory($publish) +$tested = Join-Path $fixture 'tested.dll' +$zip = Join-Path $fixture 'synckusto.zip' +$commit = 'a' * 40 +$scenario = 'success' +$calls = [System.Collections.Generic.List[object]]::new() + +# Shadow gh: these tests must never contact GitHub or create a real tag/release. +function gh { + param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments) + + $script:calls.Add($Arguments) + $global:LASTEXITCODE = 0 + if ($Arguments[0] -eq 'api' -and $Arguments[1].EndsWith('/releases?per_page=100')) { + if ($script:scenario -eq 'enumeration-failure') { + $global:LASTEXITCODE = 1 + return + } + if ($script:scenario -eq 'malformed-response') { return 'not json' } + if ($script:scenario -eq 'empty-response') { return '' } + if ($script:scenario -eq 'null-response') { return 'null' } + if ($script:scenario -eq 'object-response') { return '{}' } + if ($script:scenario -eq 'invalid-page') { return '[{}]' } + if ($script:scenario -eq 'pagination') { + return '[[{"tag_name":"v20261029"}],[{"tag_name":"v20261029.2"}]]' + } + if ($script:scenario -eq 'duplicate') { return '[[{"tag_name":"v20261029"}]]' } + return '[[]]' + } + if ($Arguments[0] -eq 'api' -and $Arguments[1].EndsWith('/tags?per_page=100')) { + if ($script:scenario -eq 'tag-enumeration-failure') { + $global:LASTEXITCODE = 1 + return + } + if ($script:scenario -eq 'pagination') { + return '[[{"name":"v20261029.1"}],[{"name":"v20261029.10"}]]' + } + return '[[]]' + } + if ($Arguments[0] -eq 'api' -and $Arguments[1] -eq '--method') { + if ($script:scenario -eq 'tag-race') { $global:LASTEXITCODE = 1 } + return '{}' + } + if ($Arguments[0] -eq 'release' -and $Arguments[1] -eq 'create') { + if ($script:scenario -eq 'upload-failure') { $global:LASTEXITCODE = 1 } + return 'https://github.com/example/repo/releases/1' + } + if ($Arguments[0] -eq 'release' -and $Arguments[1] -eq 'view') { + $asset = @{ + name = 'synckusto.zip' + state = 'uploaded' + size = (Get-Item -LiteralPath $script:zip).Length + digest = 'sha256:' + (Get-FileHash -LiteralPath $script:zip).Hash.ToLowerInvariant() + } + $release = @{ + isDraft = $true + tagName = 'v20261029' + targetCommitish = $script:commit + assets = @($asset) + url = 'https://github.com/example/repo/releases/1' + } + switch ($script:scenario) { + 'published' { $release.isDraft = $false } + 'wrong-target' { $release.targetCommitish = 'master' } + 'missing-asset' { $release.assets = @() } + 'wrong-size' { $asset.size++ } + 'wrong-digest' { $asset.digest = 'sha256:incorrect' } + 'incomplete-asset' { $asset.state = 'new' } + } + return $release | ConvertTo-Json -Depth 10 -Compress + } + if ($Arguments[0] -eq 'api' -and $Arguments[1] -like '*/git/ref/tags/*') { + $sha = $script:commit + if ($script:scenario -eq 'wrong-tag') { $sha = 'b' * 40 } + return @{ object = @{ type = 'commit'; sha = $sha } } | ConvertTo-Json -Compress + } + throw "Unexpected GitHub CLI call: $($Arguments -join ' ')" +} + +try { + Test-Case 'first release and leap day' { + Assert-Equal (Get-ReleaseVersion -Date '20261029') '20261029' + Assert-Equal (Get-ReleaseVersion -Date '20240229') '20240229' + } + Test-Case 'second and subsequent releases' { + Assert-Equal (Get-ReleaseVersion '20261029' @('v20261029')) '20261029.1' + Assert-Equal (Get-ReleaseVersion '20261029' @('v20261029', 'v20261029.1')) '20261029.2' + } + Test-Case 'numeric ordering, gaps, and missing base tag' { + Assert-Equal (Get-ReleaseVersion '20261029' @('v20261029.2', 'v20261029.10')) '20261029.11' + Assert-Equal (Get-ReleaseVersion '20261029' @('v20261029.3')) '20261029.4' + } + Test-Case 'ignore unrelated or noncanonical names' { + Assert-Equal (Get-ReleaseVersion '20261029' @( + 'v20261028', 'v202610290', 'v20261029-beta', 'v20261029.01', 'v20261029.0', 'V20261029' + )) '20261029' + } + Test-Case 'invalid dates and suffix overflow fail explicitly' { + foreach ($date in @('20260229', '20261301', '2026102', '2026-10-29', ' 20261029')) { + Assert-Fails { Get-ReleaseVersion -Date $date } 'real calendar date' + } + Assert-Fails { Get-ReleaseVersion '20261029' @('v20261029.2147483648') } 'integer range' + Assert-Fails { Get-ReleaseVersion '20261029' @('v20261029.2147483647') } 'exhausted' + } + Test-Case 'paginate drafts and standalone tags, not only published releases' { + $script:scenario = 'pagination' + $script:calls.Clear() + $reserved = @(Get-ReservedReleaseTags 'example/repo') + Assert-Equal $reserved.Count 4 + Assert-Equal (Get-ReleaseVersion '20261029' $reserved) '20261029.11' + foreach ($call in $script:calls) { + Assert-Equal ($call -contains '--paginate') $true + Assert-Equal ($call -contains '--slurp') $true + } + } + Test-Case 'API and JSON errors are not treated as an empty release history' { + foreach ($failure in @('enumeration-failure', 'tag-enumeration-failure')) { + $script:scenario = $failure + Assert-Fails { Get-ReservedReleaseTags 'example/repo' } 'failed with exit code' + } + $script:scenario = 'malformed-response' + Assert-Fails { Get-ReservedReleaseTags 'example/repo' } 'JSON' + $script:scenario = 'empty-response' + Assert-Fails { Get-ReservedReleaseTags 'example/repo' } 'empty response' + foreach ($invalid in @('null-response', 'object-response', 'invalid-page')) { + $script:scenario = $invalid + Assert-Fails { Get-ReservedReleaseTags 'example/repo' } 'invalid' + } + } + + [IO.File]::WriteAllText($tested, 'tested stamped assembly fixture') + foreach ($name in @( + 'SyncKusto.exe', 'SyncKusto.dll', 'SyncKusto.deps.json', 'SyncKusto.runtimeconfig.json', + 'SyncKusto.Core.dll', 'SyncKusto.Kusto.dll', 'SyncKusto.FileSystem.dll' + )) { + [IO.File]::WriteAllText((Join-Path $publish $name), 'tested stamped assembly fixture') + } + Test-Case 'package tested binaries at ZIP root' { + New-ReleaseArchive -PublishDirectory $publish -TestedAssembly $tested -Destination $zip + $archive = [IO.Compression.ZipFile]::OpenRead($zip) + try { + Assert-Equal $archive.Entries.Count 7 + Assert-Equal ($null -ne $archive.GetEntry('SyncKusto.exe')) $true + Assert-Equal ($null -ne $archive.GetEntry('SyncKusto.dll')) $true + } + finally { $archive.Dispose() } + } + Test-Case 'never overwrite an existing archive' { + Assert-Fails { New-ReleaseArchive $publish $tested $zip } 'already exists' + } + Test-Case 'reject stale publish output, missing runtime files, and tests' { + $otherZip = Join-Path $fixture 'invalid.zip' + [IO.File]::WriteAllText($tested, 'different build') + Assert-Fails { New-ReleaseArchive $publish $tested $otherZip } 'differs from' + [IO.File]::WriteAllText($tested, 'tested stamped assembly fixture') + $runtimeConfig = Join-Path $publish 'SyncKusto.runtimeconfig.json' + [IO.File]::Delete($runtimeConfig) + Assert-Fails { New-ReleaseArchive $publish $tested $otherZip } 'missing SyncKusto.runtimeconfig.json' + [IO.File]::WriteAllText($runtimeConfig, 'fixture') + $testAssembly = Join-Path $publish 'SyncKusto.Tests.dll' + [IO.File]::WriteAllText($testAssembly, 'fixture') + Assert-Fails { New-ReleaseArchive $publish $tested $otherZip } 'test assemblies' + [IO.File]::Delete($testAssembly) + } + Test-Case 'create draft with asset and exact source SHA, never publish it' { + $script:scenario = 'success' + $script:calls.Clear() + $result = New-DraftRelease 'example/repo' '20261029' $commit $zip + Assert-Equal $result.isDraft $true + $tagCall = @($script:calls | Where-Object { $_[0] -eq 'api' -and $_[1] -eq '--method' }) + Assert-Equal $tagCall.Count 1 + Assert-Equal ($tagCall[0] -contains "sha=$commit") $true + $create = @($script:calls | Where-Object { $_[0] -eq 'release' -and $_[1] -eq 'create' }) + Assert-Equal $create.Count 1 + foreach ($argument in @('--draft', '--verify-tag', '--generate-notes', '--target', $commit, $zip)) { + Assert-Equal ($create[0] -contains $argument) $true + } + } + Test-Case 'collisions and enumeration errors cannot cause remote writes' { + foreach ($failure in @('duplicate', 'enumeration-failure', 'tag-enumeration-failure')) { + $script:scenario = $failure + $script:calls.Clear() + $message = if ($failure -eq 'duplicate') { 'already reserved' } else { 'failed with exit code' } + Assert-Fails { New-DraftRelease 'example/repo' '20261029' $commit $zip } $message + Assert-Equal @($script:calls | Where-Object { $_ -contains 'POST' -or $_[0] -eq 'release' }).Count 0 + } + } + Test-Case 'tag race aborts without creating a release' { + $script:scenario = 'tag-race' + $script:calls.Clear() + Assert-Fails { New-DraftRelease 'example/repo' '20261029' $commit $zip } 'failed with exit code' + Assert-Equal @($script:calls | Where-Object { $_[0] -eq 'release' }).Count 0 + } + Test-Case 'attachment failure fails the workflow, leaving the draft for inspection' { + $script:scenario = 'upload-failure' + Assert-Fails { New-DraftRelease 'example/repo' '20261029' $commit $zip } 'failed with exit code' + } + Test-Case 'verify draft status, target SHA, and tag SHA' { + foreach ($failure in @('published', 'wrong-target', 'wrong-tag')) { + $script:scenario = $failure + Assert-Fails { New-DraftRelease 'example/repo' '20261029' $commit $zip } 'not a draft targeting' + } + } + Test-Case 'verify uploaded asset presence, state, size, and digest' { + foreach ($failure in @('missing-asset', 'wrong-size', 'wrong-digest', 'incomplete-asset')) { + $script:scenario = $failure + Assert-Fails { New-DraftRelease 'example/repo' '20261029' $commit $zip } 'asset verification failed' + } + } + Write-Host "$passed release automation test groups passed." +} +finally { + [IO.Directory]::Delete($fixture, $true) +} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4e0a7eb..8773808 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -36,6 +36,10 @@ jobs: } } + - name: Test release automation + shell: pwsh + run: .\.github\scripts\Test-Release.ps1 + - name: Restore Packages run: dotnet restore SyncKusto.sln --locked-mode --configfile .github\NuGet.Config -p:NuGetInteractive=false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..92df882 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,122 @@ +name: release + +on: + workflow_dispatch: + inputs: + release_date: + description: 'Optional YYYYMMDD override (defaults to the current UTC date)' + required: false + type: string + +permissions: + contents: read + +concurrency: + group: synckusto-release + queue: max + cancel-in-progress: false + +defaults: + run: + shell: pwsh + +jobs: + release: + runs-on: windows-latest + permissions: + contents: write + + steps: + - name: Require master + if: github.ref != 'refs/heads/master' + run: throw 'Run this workflow from master. No release was created.' + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.x + + - name: Test release automation + run: .\.github\scripts\Test-Release.ps1 + + - name: Assign release number + id: version + env: + GH_TOKEN: ${{ github.token }} + RELEASE_DATE: ${{ inputs.release_date }} + run: | + . .\.github\scripts\Release.ps1 + $date = $env:RELEASE_DATE + if ([string]::IsNullOrEmpty($date)) { + $date = [datetime]::UtcNow.ToString('yyyyMMdd', [cultureinfo]::InvariantCulture) + } + $version = Get-ReleaseVersion -Date $date -ExistingTags @(Get-ReservedReleaseTags -Repository $env:GITHUB_REPOSITORY) + Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value "version=$version" + Add-Content -LiteralPath $env:GITHUB_ENV -Value "RELEASE_VERSION=$version" + + - name: Verify dependency lock files + run: | + $lockFiles = @( + 'SyncKusto\packages.lock.json' + 'SyncKusto.Core\packages.lock.json' + 'SyncKusto.Kusto\packages.lock.json' + 'SyncKusto.FileSystem\packages.lock.json' + 'SyncKusto.Tests\packages.lock.json' + ) + foreach ($lockFile in $lockFiles) { + if (-not (Test-Path -LiteralPath $lockFile -PathType Leaf)) { + throw "Required dependency lock file is missing: $lockFile" + } + } + + - name: Restore Packages + run: dotnet restore SyncKusto.sln --locked-mode --configfile .github\NuGet.Config -p:NuGetInteractive=false + + - name: Build Solution + run: dotnet build SyncKusto.sln --configuration Release --no-restore "-p:BuildVersion=$env:RELEASE_VERSION" + + - name: Test offline regressions and embedded version + env: + EXPECTED_BUILD_VERSION: ${{ steps.version.outputs.version }} + run: dotnet test SyncKusto.Tests\SyncKusto.Tests.csproj --configuration Release --no-build --no-restore --filter "TestCategory=SchemaImport|TestCategory=Kusto|FullyQualifiedName~SyncKusto.Tests.Forms" -- RunConfiguration.TreatNoTestsAsError=true + + - name: Publish tested application + run: dotnet publish SyncKusto\SyncKusto.csproj --configuration Release --no-build --no-restore --self-contained false --output "$env:RUNNER_TEMP\synckusto-publish" "-p:BuildVersion=$env:RELEASE_VERSION" + + - name: Package and verify binaries + run: | + . .\.github\scripts\Release.ps1 + New-ReleaseArchive -PublishDirectory "$env:RUNNER_TEMP\synckusto-publish" ` + -TestedAssembly 'SyncKusto\bin\Release\net10.0-windows\SyncKusto.dll' ` + -Destination 'artifacts\synckusto.zip' + + - name: Retain release ZIP + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-${{ steps.version.outputs.version }} + path: artifacts\synckusto.zip + if-no-files-found: error + + - name: Create draft with binaries + env: + GH_TOKEN: ${{ github.token }} + run: | + . .\.github\scripts\Release.ps1 + $release = New-DraftRelease -Repository $env:GITHUB_REPOSITORY ` + -Version $env:RELEASE_VERSION -Commit $env:GITHUB_SHA -Archive 'artifacts\synckusto.zip' + @( + "## Draft release ready" + "" + "[$($release.tagName)]($($release.url))" + "" + "Source commit: $env:GITHUB_SHA" + "" + "Attached binary: synckusto.zip" + "" + "Review the notes and binaries, then publish the draft manually." + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57090c6..b7db239 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ Dependencies: - Windows and a stable .NET 10 SDK at or above the minimum in `global.json` (currently `10.0.402`). - For IDE development and the WinForms designer, a supported [Visual Studio 2026](https://visualstudio.microsoft.com/vs/) installation with the .NET desktop development workload. -Local builds select the highest installed stable .NET 10.0 SDK at or above that minimum using `rollForward: latestFeature`. Newer patches and feature bands are accepted; prerelease SDKs and SDKs from later major versions are not. Both build workflows install the latest stable `10.0.x` SDK and use its bundled NuGet client. +Local builds select the highest installed stable .NET 10.0 SDK at or above that minimum using `rollForward: latestFeature`. Newer patches and feature bands are accepted; prerelease SDKs and SDKs from later major versions are not. All build workflows install the latest stable `10.0.x` SDK and use its bundled NuGet client. Local development: 1. Clone the Sync Kusto repo to your local machine @@ -33,7 +33,31 @@ Local restores use the [normal NuGet configuration hierarchy](https://learn.micr The CI regression tests run offline; they do not validate live Kusto authentication or sync operations. -The main window shows a date-based build version in `Build YYYYMMDD` format (currently `Build 20261020`, for October 20, 2026). To update the displayed date, change `label2.Text` in `SyncKusto\MainForm.Designer.cs`. +The main window reads its build label from assembly metadata generated during compilation. Local builds (including the Release configuration), PR builds, and ordinary official builds show **Build dev**. No release date is stored in the form's source code, and releases never edit or commit a version number. Even a normal local build from a release tag shows **Build dev**. + +To explicitly reproduce a release stamp locally, pass `-p:BuildVersion=20261029.1` to `dotnet build` (and to `dotnet publish` if publishing). Set `EXPECTED_BUILD_VERSION` to the same value when running tests of that stamped build; otherwise the tests expect `dev`. A subsequent normal build regenerates the metadata as `dev`, without needing to clean the project. This display version is separate from the numeric CLR assembly/file versions. + +### Preparing a release + +1. Open **Actions > release > Run workflow** and select **master**. Leave `release_date` empty to use the UTC date when version preparation starts, or enter a real calendar date in `YYYYMMDD` format. +2. The workflow checks out the exact commit captured when it was dispatched, performs the locked restore, builds and runs the offline regressions, and packages the tested application. Later check-ins cannot change that release's contents; there is no special last version-bump commit or PR. +3. Open the draft link in the successful workflow's summary. The draft already contains **synckusto.zip**, with the application and its dependencies at the archive root, plus generated release notes and the source commit SHA. Review/edit the notes, download and check the binaries, then **publish the draft manually**. No manual binary attachment is needed. + +Release tags are `vYYYYMMDD` for the first release on a date, then `vYYYYMMDD.1`, `.2`, and so on. The application displays the matching **Build YYYYMMDD[.N]** label. Both drafts and existing Git tags reserve numbers. The workflow queues release runs and chooses the next highest numeric suffix, including suffixes above nine. + +The ZIP remains a framework-dependent Windows application, requiring the matching .NET 10 Windows Desktop Runtime. It is also retained as an Actions artifact named `release-`. PR and ordinary official builds do not create GitHub releases. + +Any restore, test, packaging, GitHub API, or upload error fails the workflow. A draft is only created after the tested ZIP is ready, and its tag is atomically reserved at the tested commit first. A failure during creation/upload may leave a reserved tag or incomplete draft: inspect it before publishing anything. The workflow never overwrites existing releases/assets, moves tags, deletes incomplete results, or publishes automatically. A new complete run or full rerun allocates a fresh number; it does not repair or replace an earlier draft. + +The release job explicitly requests `contents: write` for `GITHUB_TOKEN`; no PAT or repository-wide token-permission change is required. Repository/organization policies must allow that job permission and creation of release tags. + +The dependency-free release automation regressions also run in PR CI. Run them locally with PowerShell 7: + +```powershell +.\.github\scripts\Test-Release.ps1 +``` + +These regressions use a fake GitHub CLI and temporary files; they never create actual tags or releases. ### Refreshing the README screenshot diff --git a/README.md b/README.md index 7cfadaf..78272e3 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ The general usage pattern on our team is to make changes in a Kusto dev database ## Sync Kusto Binaries The release can be downloaded from the [GitHub Releases](https://github.com/microsoft/synckusto/releases) page. +Extract `synckusto.zip` and run `SyncKusto.exe`; the application requires the [.NET 10 Windows Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/10.0). Maintainers can prepare version-stamped drafts with binaries attached using the [release workflow](CONTRIBUTING.md#preparing-a-release). + ## Settings ### Temporary Databases diff --git a/SyncKusto.Tests/Forms/BuildVersionTests.cs b/SyncKusto.Tests/Forms/BuildVersionTests.cs new file mode 100644 index 0000000..6717864 --- /dev/null +++ b/SyncKusto.Tests/Forms/BuildVersionTests.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using NUnit.Framework; +using System.Reflection; +using static SyncKusto.Tests.Forms.FormsTestHelper; + +namespace SyncKusto.Tests.Forms; + +[TestFixture] +[Apartment(ApartmentState.STA)] +[NonParallelizable] +public class BuildVersionTests +{ + [Test] + public void Metadata_MatchesTheRequestedBuildVersion() + { + var attributes = typeof(MainForm).Assembly.GetCustomAttributes() + .Where(attribute => attribute.Key == "BuildVersion").ToArray(); + Assert.That(attributes, Has.Length.EqualTo(1)); + var expected = Environment.GetEnvironmentVariable("EXPECTED_BUILD_VERSION") ?? "dev"; + Assert.That(attributes[0].Value, Is.EqualTo(expected), + "The application must be rebuilt with the requested version, not reuse a previous release stamp."); + Assert.That(typeof(MainForm).Assembly.GetName().Version, Is.EqualTo(new Version(0, 0, 0, 0)), + "The display stamp must not change the existing CLR assembly version."); + } + + [TestCase(639, 815)] + [TestCase(1000, 900)] + public void Label_UsesEmbeddedVersionAndFitsAfterResizing(int width, int height) + { + using var form = new MainForm(); + var label = Field