From 05892e45ba855081b8bb896def0ce8fd4641f40c Mon Sep 17 00:00:00 2001 From: VAIO Date: Tue, 28 Jul 2026 13:17:36 +0000 Subject: [PATCH 1/2] ci: automate fail-closed releases --- .github/pull_request_template.md | 8 +- .github/workflows/ci.yml | 147 +++++++++- .github/workflows/prepare-release.yml | 154 +++++++++++ .github/workflows/publish-release.yml | 380 ++++++++++++++++++++++---- README.md | 6 + plugin.json | 2 +- scripts/resolve-release-version.ps1 | 95 +++++++ scripts/test-release-version.ps1 | 120 ++++++++ 8 files changed, 847 insertions(+), 65 deletions(-) create mode 100644 .github/workflows/prepare-release.yml create mode 100644 scripts/resolve-release-version.ps1 create mode 100644 scripts/test-release-version.ps1 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f2df4fa..4a2bff8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,20 +11,22 @@ Describe what this Pull Request changes. ## Release intent -Choose one intended release outcome for this PR: +Apply exactly one GitHub label. The label is authoritative and must not be selected only in this checklist: - [ ] `release:patch` - [ ] `release:minor` - [ ] `release:major` - [ ] `skip-release` +For same-repository PRs, release automation prepares the exact `plugin.json` version from the latest strict SemVer tag. Do not manually choose another version. `skip-release` requires the manifest version to remain unchanged. + ## Validation -- [ ] CI passes +- [ ] Required `build` check passes on the current PR SHA - [ ] Tests were added or updated when needed - [ ] Workflow files remain aligned with current `main` +- [ ] Release intent and prepared manifest version agree ## Notes Add anything reviewers or agents should know. - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c204ff..12255db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,20 @@ on: branches: [main] push: branches: [main] + workflow_dispatch: + inputs: + expected_sha: + description: Exact same-repository PR head SHA prepared by release automation + required: true + type: string + pr_number: + description: Pull Request number associated with expected_sha + required: true + type: string permissions: contents: read + pull-requests: read concurrency: group: ci-${{ github.ref }} @@ -18,12 +29,135 @@ jobs: runs-on: windows-latest steps: - - name: Checkout + - name: Checkout exact source uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.expected_sha || github.sha }} + fetch-depth: 0 + + - name: Verify checkout identity + shell: pwsh + env: + EXPECTED_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.expected_sha || github.sha }} + run: | + $ErrorActionPreference = 'Stop' + $expected = $env:EXPECTED_SHA + + $actual = (& git rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0) { + throw "git rev-parse HEAD failed with exit code $LASTEXITCODE." + } + + if ($actual -ne $expected) { + throw "Checked out '$actual' instead of expected '$expected'." + } + + Write-Host "CHECKED_OUT_SHA=$actual" + + - name: Validate PR release intent and version + if: github.event_name != 'push' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} + EXPECTED_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.expected_sha || github.event.pull_request.head.sha }} + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + function Invoke-NativeOutput { + param([string]$File, [string[]]$Arguments) + $output = @(& $File @Arguments 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "$File $($Arguments -join ' ') failed with exit code $LASTEXITCODE.`n$($output -join [Environment]::NewLine)" + } + return $output + } + + $repo = $env:REPOSITORY + $prNumber = $env:PR_NUMBER + + if ($prNumber -notmatch '^[1-9][0-9]*$') { + throw "Invalid Pull Request number '$prNumber'." + } + + $pr = (Invoke-NativeOutput gh @('api', "repos/$repo/pulls/$prNumber")) -join "`n" | ConvertFrom-Json + if ($pr.base.ref -ne 'main') { + throw "Pull Request #$prNumber does not target main." + } + + if ($env:EVENT_NAME -eq 'workflow_dispatch' -and + [string]$pr.head.sha -ne $env:EXPECTED_SHA) { + throw "Pull Request head '$($pr.head.sha)' does not match dispatched SHA '$($env:EXPECTED_SHA)'." + } + + $outcomeLabels = @( + $pr.labels | + ForEach-Object { [string]$_.name } | + Where-Object { $_ -in @('release:patch', 'release:minor', 'release:major', 'skip-release') } + ) + + if ($outcomeLabels.Count -ne 1) { + throw "Pull Request #$prNumber requires exactly one release outcome label; found: $($outcomeLabels -join ', ')." + } + + $tags = @(Invoke-NativeOutput git @('tag', '--list', 'v[0-9]*', '--sort=-v:refname')) + $latestTag = @($tags | Where-Object { $_ -match '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' } | Select-Object -First 1) + if ($latestTag.Count -ne 1) { + throw 'Could not determine exactly one latest strict SemVer tag.' + } + + $previousVersion = $latestTag[0].Substring(1) + $currentVersion = [string](Get-Content plugin.json -Raw | ConvertFrom-Json).Version + . ./scripts/resolve-release-version.ps1 + $plan = Assert-QuickSshReleaseReady ` + -PreviousVersion $previousVersion ` + -ReleaseLabel $outcomeLabels[0] ` + -CurrentVersion $currentVersion + + Write-Host "PR_NUMBER=$prNumber" + Write-Host "RELEASE_LABEL=$($plan.ReleaseLabel)" + Write-Host "PREVIOUS_VERSION=$($plan.PreviousVersion)" + Write-Host "CURRENT_VERSION=$($plan.CurrentVersion)" - name: Docs check shell: pwsh - run: ./scripts/check-docs.ps1 + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }} + run: | + $ErrorActionPreference = 'Stop' + + if ($env:EVENT_NAME -eq 'workflow_dispatch') { + $repo = $env:REPOSITORY + $prNumber = $env:PR_NUMBER + $prJson = @(& gh api "repos/$repo/pulls/$prNumber" 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "Could not load Pull Request #$prNumber.`n$($prJson -join [Environment]::NewLine)" + } + + $pr = ($prJson -join "`n") | ConvertFrom-Json + $synthetic = @{ + pull_request = @{ + base = @{ sha = [string]$pr.base.sha } + head = @{ sha = [string]$pr.head.sha } + } + } + $syntheticPath = Join-Path $env:RUNNER_TEMP 'quickssh-dispatched-pr-event.json' + $synthetic | ConvertTo-Json -Depth 6 | Set-Content $syntheticPath -Encoding utf8 + $env:GITHUB_EVENT_NAME = 'pull_request' + $env:GITHUB_EVENT_PATH = $syntheticPath + } + + ./scripts/check-docs.ps1 + + - name: Release automation tests + shell: pwsh + run: ./scripts/test-release-version.ps1 - name: Setup .NET uses: actions/setup-dotnet@v5 @@ -50,3 +184,12 @@ jobs: -r win-x64 ` --no-self-contained ` -o "ci-build" + + - name: Upload exact publish output + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v4 + with: + name: quickssh-publish-${{ github.sha }} + path: ci-build + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..c36b043 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,154 @@ +name: Prepare Release Version + +on: + pull_request_target: + types: [opened, reopened, synchronize, labeled, unlabeled] + +permissions: + contents: write + pull-requests: read + actions: write + +concurrency: + group: prepare-release-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + prepare: + if: > + github.event.pull_request.base.ref == 'main' && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: windows-latest + + steps: + - name: Checkout trusted release helper from main + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Prepare exact version and dispatch CI when changed + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + + function Invoke-NativeOutput { + param([string]$File, [string[]]$Arguments) + $output = @(& $File @Arguments 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "$File $($Arguments -join ' ') failed with exit code $LASTEXITCODE.`n$($output -join [Environment]::NewLine)" + } + return $output + } + + $event = Get-Content $env:GITHUB_EVENT_PATH -Raw | ConvertFrom-Json + $repo = $env:REPOSITORY + $prNumber = [string]$event.pull_request.number + $headRef = [string]$event.pull_request.head.ref + $eventHeadSha = [string]$event.pull_request.head.sha + $outcomeLabels = @( + $event.pull_request.labels | + ForEach-Object { [string]$_.name } | + Where-Object { $_ -in @('release:patch', 'release:minor', 'release:major', 'skip-release') } + ) + + if ($outcomeLabels.Count -ne 1) { + throw "Pull Request #$prNumber requires exactly one release outcome label; found: $($outcomeLabels -join ', ')." + } + + $label = $outcomeLabels[0] + if ($label -eq 'skip-release') { + @( + '## Release preparation' + '' + "Pull Request #$prNumber uses ``skip-release``. No version commit or release dispatch was created." + ) | Add-Content $env:GITHUB_STEP_SUMMARY + exit 0 + } + + $currentPr = (Invoke-NativeOutput gh @('api', "repos/$repo/pulls/$prNumber")) -join "`n" | ConvertFrom-Json + if ([string]$currentPr.head.sha -ne $eventHeadSha) { + throw "Pull Request head changed from '$eventHeadSha' to '$($currentPr.head.sha)' while release preparation was starting." + } + + $tags = @(Invoke-NativeOutput git @('tag', '--list', 'v[0-9]*', '--sort=-v:refname')) + $latestTag = @($tags | Where-Object { $_ -match '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' } | Select-Object -First 1) + if ($latestTag.Count -ne 1) { + throw 'Could not determine exactly one latest strict SemVer tag.' + } + + $previousVersion = $latestTag[0].Substring(1) + . ./scripts/resolve-release-version.ps1 + $expectedVersion = Get-QuickSshNextVersion ` + -PreviousVersion $previousVersion ` + -ReleaseLabel $label + + $file = (Invoke-NativeOutput gh @('api', "repos/$repo/contents/plugin.json?ref=$eventHeadSha")) -join "`n" | ConvertFrom-Json + $raw = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(([string]$file.content -replace '\s', ''))) + $manifest = $raw | ConvertFrom-Json + $currentVersion = [string]$manifest.Version + + if ($currentVersion -eq $expectedVersion) { + @( + '## Release preparation' + '' + "Pull Request #$prNumber is already prepared for ``$label`` as version ``$expectedVersion``." + ) | Add-Content $env:GITHUB_STEP_SUMMARY + exit 0 + } + + if ($currentVersion -ne $previousVersion) { + throw "plugin.json contains '$currentVersion'. Expected either current release '$previousVersion' or prepared release '$expectedVersion'." + } + + $pattern = '"Version"\s*:\s*"' + [regex]::Escape($previousVersion) + '"' + $matches = [regex]::Matches($raw, $pattern) + if ($matches.Count -ne 1) { + throw "Expected exactly one plugin Version field for '$previousVersion'; found $($matches.Count)." + } + + $updated = [regex]::Replace( + $raw, + $pattern, + '"Version": "' + $expectedVersion + '"', + 1 + ) + $encoded = [Convert]::ToBase64String([Text.UTF8Encoding]::new($false).GetBytes($updated)) + + $update = (Invoke-NativeOutput gh @( + 'api', + '--method', 'PUT', + "repos/$repo/contents/plugin.json", + '-f', "message=chore: bump version to $expectedVersion", + '-f', "content=$encoded", + '-f', "branch=$headRef", + '-f', "sha=$($file.sha)" + )) -join "`n" | ConvertFrom-Json + + $newSha = [string]$update.commit.sha + if ($newSha -notmatch '^[0-9a-f]{40}$') { + throw "GitHub did not return a valid version-bump commit SHA: '$newSha'." + } + + $null = Invoke-NativeOutput gh @( + 'workflow', 'run', 'ci.yml', + '--ref', $headRef, + '-f', "expected_sha=$newSha", + '-f', "pr_number=$prNumber" + ) + + @( + '## Release preparation' + '' + "- Pull Request: #$prNumber" + "- Release intent: ``$label``" + "- Previous version: ``$previousVersion``" + "- Prepared version: ``$expectedVersion``" + "- Version commit: ``$newSha``" + '- Exact-SHA CI dispatch: requested' + ) | Add-Content $env:GITHUB_STEP_SUMMARY diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 72a39c0..fec9032 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,12 +1,14 @@ name: Publish Release on: - pull_request_target: - types: [closed] + workflow_run: + workflows: [CI] + types: [completed] permissions: contents: write pull-requests: read + actions: read concurrency: group: publish-release-main @@ -15,90 +17,350 @@ concurrency: jobs: publish: if: > - github.event.pull_request.merged == true && - github.event.pull_request.base.ref == 'main' && - !contains(join(github.event.pull_request.labels.*.name, ','), 'skip-release') + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.head_repository.full_name == github.repository runs-on: windows-latest steps: - - name: Checkout main + - name: Checkout exact successful CI commit uses: actions/checkout@v6 with: - ref: main + ref: ${{ github.event.workflow_run.head_sha }} fetch-depth: 0 + persist-credentials: true - - name: Validate release labels + - name: Resolve merged Pull Request and release plan + id: meta shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SOURCE_SHA: ${{ github.event.workflow_run.head_sha }} + CI_RUN_ID: ${{ github.event.workflow_run.id }} run: | - $event = Get-Content $env:GITHUB_EVENT_PATH | ConvertFrom-Json - $labels = @($event.pull_request.labels | ForEach-Object { $_.name }) + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest - $releaseLabels = @($labels | Where-Object { $_ -in @("release:patch", "release:minor", "release:major") }) + function Invoke-NativeOutput { + param([string]$File, [string[]]$Arguments) + $output = @(& $File @Arguments 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "$File $($Arguments -join ' ') failed with exit code $LASTEXITCODE.`n$($output -join [Environment]::NewLine)" + } + return $output + } - if ($releaseLabels.Count -eq 0) { - Write-Error "No release label found. Add exactly one of: release:patch, release:minor, release:major" - exit 1 + $repo = $env:REPOSITORY + $sha = $env:SOURCE_SHA + $runId = $env:CI_RUN_ID + $actualSha = (Invoke-NativeOutput git @('rev-parse', 'HEAD'))[0].Trim() + if ($actualSha -ne $sha) { + throw "Checked out '$actualSha' instead of successful CI commit '$sha'." } - if ($releaseLabels.Count -gt 1) { - Write-Error "Multiple conflicting release labels found: $($releaseLabels -join ', '). Only one is allowed." - exit 1 + $prs = @( + ((Invoke-NativeOutput gh @( + 'api', + '-H', 'Accept: application/vnd.github+json', + "repos/$repo/commits/$sha/pulls" + )) -join "`n" | ConvertFrom-Json) | + Where-Object { $_.merged_at -and $_.base.ref -eq 'main' } + ) + if ($prs.Count -ne 1) { + throw "Expected one merged Pull Request associated with '$sha'; found $($prs.Count)." } - Write-Host "Release label: $($releaseLabels[0])" + $pr = $prs[0] + $outcomeLabels = @( + $pr.labels | + ForEach-Object { [string]$_.name } | + Where-Object { $_ -in @('release:patch', 'release:minor', 'release:major', 'skip-release') } + ) + if ($outcomeLabels.Count -ne 1) { + throw "Merged Pull Request #$($pr.number) requires exactly one release outcome label; found: $($outcomeLabels -join ', ')." + } - - name: Read version from plugin.json - id: meta + $label = $outcomeLabels[0] + $tags = @(Invoke-NativeOutput git @('tag', '--list', 'v[0-9]*', '--sort=-v:refname')) + $latestTag = @($tags | Where-Object { $_ -match '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' } | Select-Object -First 1) + if ($latestTag.Count -ne 1) { + throw 'Could not determine exactly one latest strict SemVer tag.' + } + + $previousVersion = $latestTag[0].Substring(1) + $currentVersion = [string](Get-Content plugin.json -Raw | ConvertFrom-Json).Version + . ./scripts/resolve-release-version.ps1 + $plan = Assert-QuickSshReleaseReady ` + -PreviousVersion $previousVersion ` + -ReleaseLabel $label ` + -CurrentVersion $currentVersion + + "pr_number=$($pr.number)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "release_label=$label" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "previous_version=$previousVersion" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "version=$currentVersion" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "tag=$($plan.Tag)" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "skip=$($plan.SkipRelease.ToString().ToLowerInvariant())" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "source_sha=$sha" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + "ci_run_id=$runId" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + + @( + '## Release plan' + '' + "- Pull Request: #$($pr.number)" + "- Release intent: ``$label``" + "- Previous version: ``$previousVersion``" + "- Source version: ``$currentVersion``" + "- Source commit: ``$sha``" + ) | Add-Content $env:GITHUB_STEP_SUMMARY + + - name: Record skipped release + if: steps.meta.outputs.skip == 'true' shell: pwsh run: | - $manifest = Get-Content "plugin.json" | ConvertFrom-Json - $version = $manifest.Version - if ([string]::IsNullOrWhiteSpace($version)) { - Write-Error "Version not found in plugin.json" - exit 1 - } - "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "tag=v$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host "Version: $version" - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '9.0.x' + @( + '' + 'Release publishing was intentionally skipped by the merged Pull Request label.' + ) | Add-Content $env:GITHUB_STEP_SUMMARY - - name: Build plugin + - name: Verify target tag and release are absent + if: steps.meta.outputs.skip != 'true' shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + RELEASE_TAG: ${{ steps.meta.outputs.tag }} run: | - dotnet publish "Flow.Launcher.Plugin.QuickSSH.csproj" ` - -c Release ` - -r win-x64 ` - --no-self-contained ` - -o "QuickSSH" + $ErrorActionPreference = 'Stop' + $tag = $env:RELEASE_TAG + $repo = $env:REPOSITORY - - name: Create ZIP + $remoteTag = @(& git ls-remote origin "refs/tags/$tag" 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "git ls-remote failed with exit code $LASTEXITCODE.`n$($remoteTag -join [Environment]::NewLine)" + } + if (@($remoteTag | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -ne 0) { + throw "Target tag '$tag' already exists." + } + + $releaseTags = @(& gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[].tag_name' 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "gh api release listing failed with exit code $LASTEXITCODE.`n$($releaseTags -join [Environment]::NewLine)" + } + $releaseTags = @($releaseTags | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($releaseTags -contains $tag) { + throw "Target GitHub release '$tag' already exists." + } + + - name: Download exact successful publish output + if: steps.meta.outputs.skip != 'true' shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SOURCE_SHA: ${{ steps.meta.outputs.source_sha }} + CI_RUN_ID: ${{ steps.meta.outputs.ci_run_id }} run: | - if (Test-Path "QuickSSH.zip") { - Remove-Item "QuickSSH.zip" -Force + $ErrorActionPreference = 'Stop' + $artifact = "quickssh-publish-$($env:SOURCE_SHA)" + & gh run download $env:CI_RUN_ID ` + --repo $env:REPOSITORY ` + --name $artifact ` + --dir release-build + if ($LASTEXITCODE -ne 0) { + throw "Could not download CI artifact '$artifact'." + } + if (-not (Test-Path 'release-build/plugin.json')) { + throw 'Downloaded publish output does not contain plugin.json at its root.' } - 7z a -tzip "QuickSSH.zip" ".\QuickSSH\*" - - - name: Create tag + - name: Build and validate release ZIP + if: steps.meta.outputs.skip != 'true' + id: package shell: pwsh + env: + EXPECTED_VERSION: ${{ steps.meta.outputs.version }} run: | - git tag "${{ steps.meta.outputs.tag }}" - git push origin "${{ steps.meta.outputs.tag }}" + $ErrorActionPreference = 'Stop' + Add-Type -AssemblyName System.IO.Compression.FileSystem - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.meta.outputs.tag }} - name: Release ${{ steps.meta.outputs.tag }} - files: QuickSSH.zip - fail_on_unmatched_files: true - draft: false - prerelease: false - generate_release_notes: true + $source = (Resolve-Path 'release-build').Path + $zip = Join-Path $PWD 'QuickSSH.zip' + if (Test-Path $zip) { + Remove-Item $zip -Force + } + + [System.IO.Compression.ZipFile]::CreateFromDirectory( + $source, + $zip, + [System.IO.Compression.CompressionLevel]::Optimal, + $false + ) + + $archive = [System.IO.Compression.ZipFile]::OpenRead($zip) + try { + $manifestEntries = @($archive.Entries | Where-Object { $_.FullName -eq 'plugin.json' }) + if ($manifestEntries.Count -ne 1) { + throw "Expected exactly one root plugin.json; found $($manifestEntries.Count)." + } + if (@($archive.Entries | Where-Object { $_.FullName -match '(^|/)win-x64/' }).Count -ne 0) { + throw 'Release ZIP contains a nested win-x64 directory.' + } + + $reader = [IO.StreamReader]::new($manifestEntries[0].Open()) + try { + $manifest = $reader.ReadToEnd() | ConvertFrom-Json + } + finally { + $reader.Dispose() + } + } + finally { + $archive.Dispose() + } + + $expectedVersion = $env:EXPECTED_VERSION + if ([string]$manifest.Version -ne $expectedVersion) { + throw "ZIP manifest version '$($manifest.Version)' does not match '$expectedVersion'." + } + + $hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() + "sha256=$hash" | Out-File $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Host "QUICKSSH_ZIP_SHA256=$hash" + + - name: Create, verify, and publish immutable release candidate + if: steps.meta.outputs.skip != 'true' + shell: pwsh env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + RELEASE_TAG: ${{ steps.meta.outputs.tag }} + SOURCE_SHA: ${{ steps.meta.outputs.source_sha }} + RELEASE_VERSION: ${{ steps.meta.outputs.version }} + EXPECTED_SHA256: ${{ steps.package.outputs.sha256 }} + run: | + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version Latest + Add-Type -AssemblyName System.IO.Compression.FileSystem + + function Invoke-Native { + param([string]$File, [string[]]$Arguments) + $output = @(& $File @Arguments 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "$File $($Arguments -join ' ') failed with exit code $LASTEXITCODE.`n$($output -join [Environment]::NewLine)" + } + return $output + } + + $repo = $env:REPOSITORY + $tag = $env:RELEASE_TAG + $sha = $env:SOURCE_SHA + $version = $env:RELEASE_VERSION + $expectedHash = $env:EXPECTED_SHA256 + $tagPushed = $false + $draftCreated = $false + $published = $false + + try { + $null = Invoke-Native git @('config', 'user.name', 'github-actions[bot]') + $null = Invoke-Native git @('config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com') + $null = Invoke-Native git @('tag', '-a', $tag, $sha, '-m', "Release $tag") + $null = Invoke-Native git @('push', 'origin', "refs/tags/$tag") + $tagPushed = $true + + $null = Invoke-Native gh @( + 'release', 'create', $tag, 'QuickSSH.zip#QuickSSH.zip', + '--repo', $repo, + '--verify-tag', + '--draft', + '--title', "Release $tag", + '--generate-notes' + ) + $draftCreated = $true + + $verifyDir = Join-Path $env:RUNNER_TEMP 'quickssh-release-verification' + Remove-Item $verifyDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item $verifyDir -ItemType Directory -Force | Out-Null + $null = Invoke-Native gh @( + 'release', 'download', $tag, + '--repo', $repo, + '--pattern', 'QuickSSH.zip', + '--dir', $verifyDir + ) + + $downloadedZip = Join-Path $verifyDir 'QuickSSH.zip' + $downloadedHash = (Get-FileHash $downloadedZip -Algorithm SHA256).Hash.ToLowerInvariant() + if ($downloadedHash -ne $expectedHash) { + throw "Published draft asset hash '$downloadedHash' does not match '$expectedHash'." + } + + $archive = [System.IO.Compression.ZipFile]::OpenRead($downloadedZip) + try { + $entry = @($archive.Entries | Where-Object { $_.FullName -eq 'plugin.json' }) + if ($entry.Count -ne 1) { + throw "Downloaded asset contains $($entry.Count) root plugin.json entries." + } + $reader = [IO.StreamReader]::new($entry[0].Open()) + try { + $downloadedVersion = [string](($reader.ReadToEnd() | ConvertFrom-Json).Version) + } + finally { + $reader.Dispose() + } + } + finally { + $archive.Dispose() + } + + if ($downloadedVersion -ne $version) { + throw "Downloaded asset version '$downloadedVersion' does not match '$version'." + } + + $peeled = @(Invoke-Native git @('ls-remote', 'origin', "refs/tags/$tag^{}")) + $tagCommit = @($peeled | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })[0].Split("`t")[0] + if ($tagCommit -ne $sha) { + throw "Remote tag '$tag' resolves to '$tagCommit' instead of '$sha'." + } + + $null = Invoke-Native gh @( + 'release', 'edit', $tag, + '--repo', $repo, + '--draft=false', + '--latest' + ) + $published = $true + + @( + '' + '## Published release' + '' + "- Tag: ``$tag``" + "- Source commit: ``$sha``" + "- QuickSSH.zip SHA-256: ``$expectedHash``" + '- Draft asset verification: PASS' + '- Embedded version verification: PASS' + '- Tag/source verification: PASS' + '- Release state: published and latest' + ) | Add-Content $env:GITHUB_STEP_SUMMARY + } + catch { + $failure = $_ + if (-not $published) { + if ($draftCreated) { + & gh release delete $tag --repo $repo --cleanup-tag --yes 2>&1 | Write-Warning + if ($LASTEXITCODE -ne 0) { + Write-Warning "Draft release rollback failed with exit code $LASTEXITCODE." + } + } + elseif ($tagPushed) { + & git push origin ":refs/tags/$tag" 2>&1 | Write-Warning + if ($LASTEXITCODE -ne 0) { + Write-Warning "Remote tag rollback failed with exit code $LASTEXITCODE." + } + } + & git tag -d $tag 2>&1 | Out-Null + } + throw $failure + } diff --git a/README.md b/README.md index 1f23104..fde7648 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,12 @@ Restart Flow Launcher so it reloads the plugin assets. Issues and pull requests are welcome. User-facing behavior changes should include corresponding public README updates. +### Release automation + +Every Pull Request must carry exactly one release outcome label: `release:patch`, `release:minor`, `release:major`, or `skip-release`. For same-repository PRs, the release preparation workflow derives the exact next strict SemVer version from the latest tag and updates only `plugin.json` when a release bump is required. + +The `build` check is required on the current PR SHA. After a release PR is merged, a successful CI run on the exact `main` commit supplies the publish output to the release workflow. Publishing stops before creating anything when the version does not match the label or when the target tag or release already exists. A new release is first created as a draft, its ZIP hash, embedded manifest version, and tag-to-source binding are verified, and only then is it published as the latest release. Existing release assets are never clobbered. + ## License QuickSSH is released under the [MIT License](LICENSE). diff --git a/plugin.json b/plugin.json index aefb881..b52c1e0 100644 --- a/plugin.json +++ b/plugin.json @@ -4,7 +4,7 @@ "Name": "QuickSSH", "Description": "Manage SSH profiles, reusable remote actions, SSH keys, config imports, and custom shells from Flow Launcher", "Author": "Vaso73", - "Version": "3.6.0", + "Version": "3.6.1", "Language": "csharp", "Website": "https://github.com/Vaso73/Flow.Launcher.Plugin.QuickSSH", "IcoPath": "Images\\app.png", diff --git a/scripts/resolve-release-version.ps1 b/scripts/resolve-release-version.ps1 new file mode 100644 index 0000000..39fca86 --- /dev/null +++ b/scripts/resolve-release-version.ps1 @@ -0,0 +1,95 @@ +[CmdletBinding()] +param( + [string]$PreviousVersion, + [string]$ReleaseLabel, + [string]$CurrentVersion +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +function ConvertTo-QuickSshVersion { + param( + [Parameter(Mandatory = $true)] + [string]$Value, + + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ($Value -notmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { + throw "$Name must use strict major.minor.patch SemVer without prerelease or build metadata: '$Value'." + } + + return [pscustomobject]@{ + Major = [int]$Matches[1] + Minor = [int]$Matches[2] + Patch = [int]$Matches[3] + Text = $Value + } +} + +function Get-QuickSshNextVersion { + param( + [Parameter(Mandatory = $true)] + [string]$PreviousVersion, + + [Parameter(Mandatory = $true)] + [string]$ReleaseLabel + ) + + $previous = ConvertTo-QuickSshVersion -Value $PreviousVersion -Name 'PreviousVersion' + + switch ($ReleaseLabel) { + 'release:patch' { return "$($previous.Major).$($previous.Minor).$($previous.Patch + 1)" } + 'release:minor' { return "$($previous.Major).$($previous.Minor + 1).0" } + 'release:major' { return "$($previous.Major + 1).0.0" } + 'skip-release' { return $previous.Text } + default { throw "Unsupported release label '$ReleaseLabel'." } + } +} + +function Assert-QuickSshReleaseReady { + param( + [Parameter(Mandatory = $true)] + [string]$PreviousVersion, + + [Parameter(Mandatory = $true)] + [string]$ReleaseLabel, + + [Parameter(Mandatory = $true)] + [string]$CurrentVersion + ) + + $null = ConvertTo-QuickSshVersion -Value $CurrentVersion -Name 'CurrentVersion' + $expected = Get-QuickSshNextVersion -PreviousVersion $PreviousVersion -ReleaseLabel $ReleaseLabel + + if ($CurrentVersion -ne $expected) { + throw "Release label '$ReleaseLabel' requires plugin version '$expected' after '$PreviousVersion', but plugin.json contains '$CurrentVersion'." + } + + return [pscustomobject]@{ + PreviousVersion = $PreviousVersion + CurrentVersion = $CurrentVersion + ExpectedVersion = $expected + ReleaseLabel = $ReleaseLabel + SkipRelease = ($ReleaseLabel -eq 'skip-release') + Tag = "v$CurrentVersion" + } +} + +if ($PSBoundParameters.ContainsKey('PreviousVersion') -or + $PSBoundParameters.ContainsKey('ReleaseLabel') -or + $PSBoundParameters.ContainsKey('CurrentVersion')) { + if (-not $PSBoundParameters.ContainsKey('PreviousVersion') -or + -not $PSBoundParameters.ContainsKey('ReleaseLabel') -or + -not $PSBoundParameters.ContainsKey('CurrentVersion')) { + throw 'PreviousVersion, ReleaseLabel, and CurrentVersion must be supplied together.' + } + + Assert-QuickSshReleaseReady ` + -PreviousVersion $PreviousVersion ` + -ReleaseLabel $ReleaseLabel ` + -CurrentVersion $CurrentVersion | + ConvertTo-Json -Compress +} diff --git a/scripts/test-release-version.ps1 b/scripts/test-release-version.ps1 new file mode 100644 index 0000000..4143976 --- /dev/null +++ b/scripts/test-release-version.ps1 @@ -0,0 +1,120 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'resolve-release-version.ps1') + +$failures = [System.Collections.Generic.List[string]]::new() + +function Assert-Equal { + param( + [Parameter(Mandatory = $true)] + [object]$Actual, + + [Parameter(Mandatory = $true)] + [object]$Expected, + + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ($Actual -ne $Expected) { + $failures.Add("$Name expected '$Expected' but got '$Actual'.") + } +} + +function Assert-Throws { + param( + [Parameter(Mandatory = $true)] + [scriptblock]$Action, + + [Parameter(Mandatory = $true)] + [string]$Name + ) + + try { + & $Action + $failures.Add("$Name did not throw.") + } + catch { + Write-Host "PASS expected failure: $Name" + } +} + +Assert-Equal ` + -Actual (Get-QuickSshNextVersion -PreviousVersion '3.6.0' -ReleaseLabel 'release:patch') ` + -Expected '3.6.1' ` + -Name 'patch bump' + +Assert-Equal ` + -Actual (Get-QuickSshNextVersion -PreviousVersion '3.6.0' -ReleaseLabel 'release:minor') ` + -Expected '3.7.0' ` + -Name 'minor bump' + +Assert-Equal ` + -Actual (Get-QuickSshNextVersion -PreviousVersion '3.6.0' -ReleaseLabel 'release:major') ` + -Expected '4.0.0' ` + -Name 'major bump' + +Assert-Equal ` + -Actual (Get-QuickSshNextVersion -PreviousVersion '3.6.0' -ReleaseLabel 'skip-release') ` + -Expected '3.6.0' ` + -Name 'skip release' + +$ready = Assert-QuickSshReleaseReady ` + -PreviousVersion '3.6.0' ` + -ReleaseLabel 'release:patch' ` + -CurrentVersion '3.6.1' + +Assert-Equal -Actual $ready.Tag -Expected 'v3.6.1' -Name 'release tag' +Assert-Equal -Actual $ready.SkipRelease -Expected $false -Name 'release flag' + +Assert-Throws ` + -Name 'unchanged release version' ` + -Action { + Assert-QuickSshReleaseReady ` + -PreviousVersion '3.6.0' ` + -ReleaseLabel 'release:patch' ` + -CurrentVersion '3.6.0' + } + +Assert-Throws ` + -Name 'skipped patch version' ` + -Action { + Assert-QuickSshReleaseReady ` + -PreviousVersion '3.6.0' ` + -ReleaseLabel 'release:patch' ` + -CurrentVersion '3.6.2' + } + +Assert-Throws ` + -Name 'unsupported label' ` + -Action { + Get-QuickSshNextVersion ` + -PreviousVersion '3.6.0' ` + -ReleaseLabel 'release:banana' + } + +Assert-Throws ` + -Name 'invalid previous SemVer' ` + -Action { + Get-QuickSshNextVersion ` + -PreviousVersion 'v3.6.0' ` + -ReleaseLabel 'release:patch' + } + +Assert-Throws ` + -Name 'invalid current SemVer' ` + -Action { + Assert-QuickSshReleaseReady ` + -PreviousVersion '3.6.0' ` + -ReleaseLabel 'release:patch' ` + -CurrentVersion '3.6.1-beta' + } + +if ($failures.Count -gt 0) { + $failures | ForEach-Object { Write-Error $_ } + exit 1 +} + +Write-Host 'RELEASE_VERSION_TESTS=PASS' +exit 0 From 6d575ab2fdd6d9258434c0fc852795c6f073217e Mon Sep 17 00:00:00 2001 From: VAIO Date: Tue, 28 Jul 2026 13:38:40 +0000 Subject: [PATCH 2/2] fix: handle single release tag results --- .github/workflows/ci.yml | 14 ++++------ .github/workflows/prepare-release.yml | 11 ++------ .github/workflows/publish-release.yml | 37 +++++++++++++++---------- scripts/resolve-release-version.ps1 | 40 +++++++++++++++++++++++++++ scripts/test-release-version.ps1 | 22 +++++++++++++++ 5 files changed, 93 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12255db..7b34f7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,18 +103,14 @@ jobs: throw "Pull Request #$prNumber requires exactly one release outcome label; found: $($outcomeLabels -join ', ')." } - $tags = @(Invoke-NativeOutput git @('tag', '--list', 'v[0-9]*', '--sort=-v:refname')) - $latestTag = @($tags | Where-Object { $_ -match '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' } | Select-Object -First 1) - if ($latestTag.Count -ne 1) { - throw 'Could not determine exactly one latest strict SemVer tag.' - } - - $previousVersion = $latestTag[0].Substring(1) - $currentVersion = [string](Get-Content plugin.json -Raw | ConvertFrom-Json).Version . ./scripts/resolve-release-version.ps1 + $tags = @(Invoke-NativeOutput git @('tag', '--list', 'v[0-9]*')) + $previousVersion = Get-QuickSshLatestTaggedVersion -Tags ([string[]]$tags) + $currentVersion = [string](Get-Content plugin.json -Raw | ConvertFrom-Json).Version + $releaseLabel = [string]($outcomeLabels | Select-Object -First 1) $plan = Assert-QuickSshReleaseReady ` -PreviousVersion $previousVersion ` - -ReleaseLabel $outcomeLabels[0] ` + -ReleaseLabel $releaseLabel ` -CurrentVersion $currentVersion Write-Host "PR_NUMBER=$prNumber" diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index c36b043..45f52be 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -61,7 +61,7 @@ jobs: throw "Pull Request #$prNumber requires exactly one release outcome label; found: $($outcomeLabels -join ', ')." } - $label = $outcomeLabels[0] + $label = [string]($outcomeLabels | Select-Object -First 1) if ($label -eq 'skip-release') { @( '## Release preparation' @@ -76,14 +76,9 @@ jobs: throw "Pull Request head changed from '$eventHeadSha' to '$($currentPr.head.sha)' while release preparation was starting." } - $tags = @(Invoke-NativeOutput git @('tag', '--list', 'v[0-9]*', '--sort=-v:refname')) - $latestTag = @($tags | Where-Object { $_ -match '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' } | Select-Object -First 1) - if ($latestTag.Count -ne 1) { - throw 'Could not determine exactly one latest strict SemVer tag.' - } - - $previousVersion = $latestTag[0].Substring(1) . ./scripts/resolve-release-version.ps1 + $tags = @(Invoke-NativeOutput git @('tag', '--list', 'v[0-9]*')) + $previousVersion = Get-QuickSshLatestTaggedVersion -Tags ([string[]]$tags) $expectedVersion = Get-QuickSshNextVersion ` -PreviousVersion $previousVersion ` -ReleaseLabel $label diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index fec9032..0c37973 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -55,7 +55,11 @@ jobs: $repo = $env:REPOSITORY $sha = $env:SOURCE_SHA $runId = $env:CI_RUN_ID - $actualSha = (Invoke-NativeOutput git @('rev-parse', 'HEAD'))[0].Trim() + $actualSha = [string](Invoke-NativeOutput git @('rev-parse', 'HEAD') | Select-Object -First 1) + $actualSha = $actualSha.Trim() + if ($actualSha -notmatch '^[0-9a-f]{40}$') { + throw "git rev-parse HEAD returned an invalid SHA '$actualSha'." + } if ($actualSha -ne $sha) { throw "Checked out '$actualSha' instead of successful CI commit '$sha'." } @@ -72,7 +76,7 @@ jobs: throw "Expected one merged Pull Request associated with '$sha'; found $($prs.Count)." } - $pr = $prs[0] + $pr = $prs | Select-Object -First 1 $outcomeLabels = @( $pr.labels | ForEach-Object { [string]$_.name } | @@ -82,16 +86,11 @@ jobs: throw "Merged Pull Request #$($pr.number) requires exactly one release outcome label; found: $($outcomeLabels -join ', ')." } - $label = $outcomeLabels[0] - $tags = @(Invoke-NativeOutput git @('tag', '--list', 'v[0-9]*', '--sort=-v:refname')) - $latestTag = @($tags | Where-Object { $_ -match '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' } | Select-Object -First 1) - if ($latestTag.Count -ne 1) { - throw 'Could not determine exactly one latest strict SemVer tag.' - } - - $previousVersion = $latestTag[0].Substring(1) - $currentVersion = [string](Get-Content plugin.json -Raw | ConvertFrom-Json).Version + $label = [string]($outcomeLabels | Select-Object -First 1) . ./scripts/resolve-release-version.ps1 + $tags = @(Invoke-NativeOutput git @('tag', '--list', 'v[0-9]*')) + $previousVersion = Get-QuickSshLatestTaggedVersion -Tags ([string[]]$tags) + $currentVersion = [string](Get-Content plugin.json -Raw | ConvertFrom-Json).Version $plan = Assert-QuickSshReleaseReady ` -PreviousVersion $previousVersion ` -ReleaseLabel $label ` @@ -209,7 +208,8 @@ jobs: throw 'Release ZIP contains a nested win-x64 directory.' } - $reader = [IO.StreamReader]::new($manifestEntries[0].Open()) + $manifestEntry = $manifestEntries | Select-Object -First 1 + $reader = [IO.StreamReader]::new($manifestEntry.Open()) try { $manifest = $reader.ReadToEnd() | ConvertFrom-Json } @@ -302,7 +302,8 @@ jobs: if ($entry.Count -ne 1) { throw "Downloaded asset contains $($entry.Count) root plugin.json entries." } - $reader = [IO.StreamReader]::new($entry[0].Open()) + $manifestEntry = $entry | Select-Object -First 1 + $reader = [IO.StreamReader]::new($manifestEntry.Open()) try { $downloadedVersion = [string](($reader.ReadToEnd() | ConvertFrom-Json).Version) } @@ -319,7 +320,15 @@ jobs: } $peeled = @(Invoke-Native git @('ls-remote', 'origin', "refs/tags/$tag^{}")) - $tagCommit = @($peeled | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })[0].Split("`t")[0] + $tagReference = [string]( + $peeled | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -First 1 + ) + if ($tagReference -notmatch '^([0-9a-f]{40})\s+refs/tags/') { + throw "Remote tag '$tag' returned an invalid peeled reference '$tagReference'." + } + $tagCommit = [string]$Matches[1] if ($tagCommit -ne $sha) { throw "Remote tag '$tag' resolves to '$tagCommit' instead of '$sha'." } diff --git a/scripts/resolve-release-version.ps1 b/scripts/resolve-release-version.ps1 index 39fca86..a662b6c 100644 --- a/scripts/resolve-release-version.ps1 +++ b/scripts/resolve-release-version.ps1 @@ -29,6 +29,46 @@ function ConvertTo-QuickSshVersion { } } + +function Get-QuickSshLatestTaggedVersion { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [string[]]$Tags + ) + + $candidates = @( + foreach ($tagValue in $Tags) { + $tag = [string]$tagValue + if ($tag -match '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { + $major = [int]$Matches[1] + $minor = [int]$Matches[2] + $patch = [int]$Matches[3] + + [pscustomobject]@{ + Tag = $tag + Version = [version]::new($major, $minor, $patch) + } + } + } + ) + + if ($candidates.Count -eq 0) { + throw 'Could not determine a latest strict SemVer tag.' + } + + $latest = $candidates | + Sort-Object -Property Version -Descending | + Select-Object -First 1 + + $latestTag = [string]$latest.Tag + if ([string]::IsNullOrWhiteSpace($latestTag)) { + throw 'Latest strict SemVer tag resolved to an empty value.' + } + + return $latestTag.Substring(1) +} + function Get-QuickSshNextVersion { param( [Parameter(Mandatory = $true)] diff --git a/scripts/test-release-version.ps1 b/scripts/test-release-version.ps1 index 4143976..e5e57fd 100644 --- a/scripts/test-release-version.ps1 +++ b/scripts/test-release-version.ps1 @@ -40,6 +40,28 @@ function Assert-Throws { } } + +Assert-Equal ` + -Actual (Get-QuickSshLatestTaggedVersion -Tags @('v3.6.0')) ` + -Expected '3.6.0' ` + -Name 'single tag remains a complete scalar value' + +Assert-Equal ` + -Actual (Get-QuickSshLatestTaggedVersion -Tags @('v3.7.2', 'v4.0.0', 'v3.10.0')) ` + -Expected '4.0.0' ` + -Name 'latest tag uses semantic ordering' + +Assert-Equal ` + -Actual (Get-QuickSshLatestTaggedVersion -Tags @('preview', 'v3.6.1-beta', 'v3.6.0')) ` + -Expected '3.6.0' ` + -Name 'non-strict tags are ignored' + +Assert-Throws ` + -Name 'no strict SemVer tag' ` + -Action { + Get-QuickSshLatestTaggedVersion -Tags @('preview', 'v3.6.1-beta') + } + Assert-Equal ` -Actual (Get-QuickSshNextVersion -PreviousVersion '3.6.0' -ReleaseLabel 'release:patch') ` -Expected '3.6.1' `