Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

143 changes: 141 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand All @@ -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
149 changes: 149 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -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
Loading