From 4680b3fb2410aa8c91cfc81b8bbd7739621a7bc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:21:06 +0000 Subject: [PATCH] release: allow the release workflow to be run manually Pushing to refs/tags is not available from every environment the project is worked on from, and a tag push is also a one-shot trigger: a release that fails halfway through cannot be retried without inventing a new version number. The workflow now also accepts a manual run with the tag as an input, and creates that tag itself against the exact commit it built. The input is passed through the environment rather than interpolated into the script body, so it cannot inject PowerShell, and it must look like a version tag before anything else runs. Also refuses to run when a release for the tag already exists, which would otherwise either fail late or replace files people have downloaded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KqeMPekXsH3e1tSgJkWYyz --- .github/workflows/release.yml | 54 +++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cced3d..90f44f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,10 @@ name: Release # Builds the three published artifacts on a real Windows runner and attaches -# them to a GitHub Release. Triggered by pushing a version tag, e.g. v1.1.3. +# them to a GitHub Release. Triggered by pushing a version tag (v1.1.3), or run +# manually with the tag as an input — the manual path creates the tag itself, so +# a release can be cut without local push access to refs/tags, and a failed +# release can be retried without inventing a new version number. # # The build itself is build-release.ps1 — the same script used locally — so a # release built here and one built by hand cannot drift apart. @@ -9,6 +12,12 @@ name: Release on: push: tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Version tag to publish, e.g. v1.1.3" + required: true + type: string permissions: contents: write @@ -21,6 +30,21 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # Passed through the environment rather than interpolated into the script + # body, so the input cannot inject PowerShell. + - name: Resolve release tag + shell: pwsh + env: + INPUT_TAG: ${{ inputs.tag }} + run: | + $tag = if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch') { $env:INPUT_TAG } else { $env:GITHUB_REF_NAME } + $tag = $tag.Trim() + if ($tag -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$') { + throw "Refusing to publish an unexpected tag format: '$tag'" + } + Add-Content -Path $env:GITHUB_ENV -Value "RELEASE_TAG=$tag" + Write-Host "Publishing $tag" + - name: Set up .NET 10 uses: actions/setup-dotnet@v4 with: @@ -37,7 +61,7 @@ jobs: - name: Verify tag matches project version shell: pwsh run: | - $tag = $env:GITHUB_REF_NAME + $tag = $env:RELEASE_TAG $scriptVersion = (Select-String -Path build-release.ps1 -Pattern '^\$Version = "(.+)"').Matches[0].Groups[1].Value $csprojVersion = ([xml](Get-Content WinBatLens.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } $issVersion = (Select-String -Path installer/WinBatLens.iss -Pattern '#define MyAppVersion "(.+)"').Matches[0].Groups[1].Value @@ -48,6 +72,17 @@ jobs: if ($csprojVersion -ne $scriptVersion) { throw "WinBatLens.csproj version $csprojVersion does not match $scriptVersion" } if ($issVersion -ne $scriptVersion) { throw "installer/WinBatLens.iss version $issVersion does not match $scriptVersion" } + # Publishing the same version twice would either fail late or quietly + # replace what people have already downloaded. + - name: Verify the release does not already exist + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release view $env:RELEASE_TAG 2>$null + if ($LASTEXITCODE -eq 0) { throw "Release $env:RELEASE_TAG already exists" } + Write-Host "$env:RELEASE_TAG is free" + - name: Build release packages shell: pwsh run: ./build-release.ps1 @@ -55,7 +90,7 @@ jobs: - name: Verify all three artifacts exist shell: pwsh run: | - $version = $env:GITHUB_REF_NAME.TrimStart('v') + $version = $env:RELEASE_TAG.TrimStart('v') $expected = @( "dist/WinBatLens_v${version}_Setup_x64.exe", "dist/WinBatLens_v${version}_Portable_x64.exe", @@ -70,7 +105,7 @@ jobs: - name: Upload build artifacts uses: actions/upload-artifact@v4 with: - name: winbatlens-${{ github.ref_name }} + name: winbatlens-${{ env.RELEASE_TAG }} path: dist/* if-no-files-found: error @@ -87,7 +122,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - $tag = $env:GITHUB_REF_NAME + $tag = $env:RELEASE_TAG $notes = ".github/release-notes/$tag.md" $isPrerelease = $tag -match '^v[0-9.]+-' @@ -95,7 +130,14 @@ jobs: $isPrerelease = $true } - $ghArgs = @("release", "create", $tag, "--title", "WinBat Lens $tag", "--verify-tag") + $ghArgs = @("release", "create", $tag, "--title", "WinBat Lens $tag") + + # On a tag push the tag is already there and must be the one we built. + # On a manual run it does not exist yet, so it is created here against + # the exact commit that produced these artifacts. + if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch') { $ghArgs += @("--target", $env:GITHUB_SHA) } + else { $ghArgs += "--verify-tag" } + if ($isPrerelease) { $ghArgs += "--prerelease" } if (Test-Path $notes) { $ghArgs += @("--notes-file", $notes) } else { $ghArgs += "--generate-notes" }