name: release on: workflow_dispatch: inputs: version: description: "Release version, for example 1.2.24 or v1.2.24. Leave empty to bump automatically." required: false type: string bump: description: "Version bump to use when version is empty." required: true default: patch type: choice options: - patch - minor - major draft: description: "Create the GitHub Release as a draft." required: true default: false type: boolean prerelease: description: "Mark the GitHub Release as a prerelease." required: true default: false type: boolean permissions: contents: write concurrency: group: release cancel-in-progress: false jobs: windows: name: Prepare and publish Windows release runs-on: windows-latest steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup .NET uses: actions/setup-dotnet@v5 with: dotnet-version: "8.0.x" - name: Prepare release metadata id: meta shell: pwsh run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" [xml]$project = Get-Content "AppTunnel/AppTunnel.csproj" $currentVersion = [version]$project.Project.PropertyGroup.Version $requestedVersion = "${{ inputs.version }}".Trim() if ($requestedVersion) { $requestedVersion = $requestedVersion.TrimStart("v") if ($requestedVersion -notmatch '^\d+\.\d+\.\d+$') { throw "Version must use MAJOR.MINOR.PATCH format. Received: $requestedVersion" } $nextVersion = [version]$requestedVersion } else { $bump = "${{ inputs.bump }}" if ($bump -eq "major") { $nextVersion = [version]::new($currentVersion.Major + 1, 0, 0) } elseif ($bump -eq "minor") { $nextVersion = [version]::new($currentVersion.Major, $currentVersion.Minor + 1, 0) } else { $nextVersion = [version]::new($currentVersion.Major, $currentVersion.Minor, $currentVersion.Build + 1) } } if ($nextVersion -lt $currentVersion -or (-not $requestedVersion -and $nextVersion -le $currentVersion)) { throw "Release version ($nextVersion) must be greater than current project version ($currentVersion)." } $version = $nextVersion.ToString(3) $tag = "v$version" git fetch --tags origin $existingTag = git tag --list $tag if ($existingTag) { throw "Tag $tag already exists." } $artifactName = "TunnelX-$tag-standalone-compressed.exe" "version=$version" >> $env:GITHUB_OUTPUT "tag=$tag" >> $env:GITHUB_OUTPUT "artifact_name=$artifactName" >> $env:GITHUB_OUTPUT - name: Update version and changelog id: release_notes shell: pwsh run: | $version = "${{ steps.meta.outputs.version }}" $tag = "${{ steps.meta.outputs.tag }}" $date = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd") [xml]$project = Get-Content "AppTunnel/AppTunnel.csproj" $project.Project.PropertyGroup.Version = $version $project.Project.PropertyGroup.AssemblyVersion = "$version.0" $project.Project.PropertyGroup.FileVersion = "$version.0" $project.Save((Resolve-Path "AppTunnel/AppTunnel.csproj")) $changelogPath = "CHANGELOG.md" $changelog = Get-Content -Raw -LiteralPath $changelogPath if ($changelog -notmatch '(?m)^## Unreleased\s*$') { throw "CHANGELOG.md must contain a '## Unreleased' section." } $existingReleaseMatch = [regex]::Match($changelog, "(?ms)^## $([regex]::Escape($version))\s+-\s+.*?\r?\n(?.*?)(?=^##\s|\z)") if ($existingReleaseMatch.Success) { $notes = $existingReleaseMatch.Groups["notes"].Value.Trim() } else { $unreleasedMatch = [regex]::Match($changelog, '(?ms)^## Unreleased\s*(?.*?)(?=^##\s|\z)') $notes = $unreleasedMatch.Groups["notes"].Value.Trim() if (-not $notes) { $previousTag = git describe --tags --abbrev=0 2>$null if ($LASTEXITCODE -eq 0 -and $previousTag) { $subjects = git log "$previousTag..HEAD" --pretty=format:"- %s" } else { $subjects = git log --pretty=format:"- %s" } $notes = ($subjects | Where-Object { $_ -and ($_ -notmatch '^- Prepare release v') }) -join "`n" if (-not $notes) { $notes = "- Maintenance release." } } $releaseHeading = "## $version - $date" $replacement = "## Unreleased`n`n$releaseHeading`n`n$notes`n`n" $changelog = [regex]::Replace($changelog, '(?ms)^## Unreleased\s*(?.*?)(?=^##\s|\z)', $replacement, 1) Set-Content -Encoding UTF8 -LiteralPath $changelogPath -Value $changelog } $notesFile = Join-Path $env:RUNNER_TEMP "release-notes.md" $notes | Set-Content -Encoding UTF8 -LiteralPath $notesFile "notes_file=$notesFile" >> $env:GITHUB_OUTPUT - name: Commit release changes id: release_commit shell: pwsh run: | $tag = "${{ steps.meta.outputs.tag }}" git add AppTunnel/AppTunnel.csproj CHANGELOG.md if (git diff --cached --quiet) { Write-Host "No release metadata changes to commit." } else { git commit -m "Prepare release $tag" } $releaseSha = git rev-parse HEAD git tag $tag git push origin HEAD:${{ github.ref_name }} git push origin $tag "sha=$releaseSha" >> $env:GITHUB_OUTPUT - name: Restore run: dotnet restore AppTunnel.sln - name: Build run: dotnet build AppTunnel.sln -c Release --no-restore - name: Publish standalone executable run: > dotnet publish AppTunnel\AppTunnel.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false -o publish\TunnelX - name: Package release asset id: package shell: pwsh run: | $source = "publish/TunnelX/TunnelX.exe" $asset = "publish/${{ steps.meta.outputs.artifact_name }}" $checksum = "$asset.sha256" if (-not (Test-Path $source)) { throw "Published executable was not found at $source" } Move-Item -LiteralPath $source -Destination $asset $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $asset).Hash.ToLowerInvariant() "$hash ${{ steps.meta.outputs.artifact_name }}" | Set-Content -Encoding ASCII -LiteralPath $checksum "asset=$asset" >> $env:GITHUB_OUTPUT "checksum=$checksum" >> $env:GITHUB_OUTPUT "sha256=$hash" >> $env:GITHUB_OUTPUT - name: Upload workflow artifact uses: actions/upload-artifact@v6 with: name: TunnelX-${{ steps.meta.outputs.tag }}-win-x64 path: | ${{ steps.package.outputs.asset }} ${{ steps.package.outputs.checksum }} if-no-files-found: error - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} shell: pwsh run: | $tag = "${{ steps.meta.outputs.tag }}" $title = "TunnelX $tag" $runUrl = "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" $sha256 = "${{ steps.package.outputs.sha256 }}".ToUpperInvariant() $artifactName = "${{ steps.meta.outputs.artifact_name }}" $notes = Get-Content -Raw -LiteralPath "${{ steps.release_notes.outputs.notes_file }}" $provenanceLines = @( "", "## Build provenance", "", "- Built and uploaded by GitHub Actions.", "- Workflow: ``release``", "- Run: $runUrl", "- Commit: ``${{ steps.release_commit.outputs.sha }}``", "- SHA256: ``$sha256 $artifactName``", "" ) $notes = "$($notes.Trim())`n`n$($provenanceLines -join "`n")" $notesFile = Join-Path $env:RUNNER_TEMP "final-release-notes.md" $notes | Set-Content -Encoding UTF8 -LiteralPath $notesFile $releaseArgs = @( "release", "create", $tag, "${{ steps.package.outputs.asset }}", "${{ steps.package.outputs.checksum }}", "--title", $title, "--notes-file", $notesFile, "--latest" ) if ("${{ inputs.draft }}" -eq "true") { $releaseArgs += "--draft" } if ("${{ inputs.prerelease }}" -eq "true") { $releaseArgs += "--prerelease" } gh @releaseArgs