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..7b34f7a 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,131 @@ 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 ', ')." + } + + . ./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 $releaseLabel ` + -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 +180,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..45f52be --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,149 @@ +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 = [string]($outcomeLabels | Select-Object -First 1) + 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." + } + + . ./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 + + $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..0c37973 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,359 @@ 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 = [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'." } - 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 | Select-Object -First 1 + $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 = [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 ` + -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 + + $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: Create ZIP + - 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.' + } + + $manifestEntry = $manifestEntries | Select-Object -First 1 + $reader = [IO.StreamReader]::new($manifestEntry.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." + } + $manifestEntry = $entry | Select-Object -First 1 + $reader = [IO.StreamReader]::new($manifestEntry.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^{}")) + $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'." + } + + $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..a662b6c --- /dev/null +++ b/scripts/resolve-release-version.ps1 @@ -0,0 +1,135 @@ +[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-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)] + [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..e5e57fd --- /dev/null +++ b/scripts/test-release-version.ps1 @@ -0,0 +1,142 @@ +$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-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' ` + -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