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..b550201 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Assuming that the user already has a Kusto database with functions and tables, s The general usage pattern on our team is to make changes in a Kusto dev database which starts with a schema that mirrors the production database. Once the developer is satisfied with the changes, they use Sync Kusto with the development database as the source and their local file system as the target. The changes are committed to source control and a pull request is sent out for review. After the request is approved and the code merges to master, the developer syncs the master branch and uses Sync Kusto to sync from their local file system to the production Kusto database. This is a more flexible/manual version of the [Azure Data Explorer task for Azure Dev Ops](https://docs.microsoft.com/en-us/azure/data-explorer/devops). We've had great success with this process and use the tool daily to help with our development and for deploying to production assets. ## Sync Kusto Binaries -The release can be downloaded from the [GitHub Releases](https://github.com/microsoft/synckusto/releases) page. +The release can be downloaded from the [GitHub Releases](https://github.com/microsoft/synckusto/releases) page. Extract `synckusto.zip` and run `SyncKusto.exe`. [.NET 10 Windows Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/10.0) is required. ## Settings 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