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
243 changes: 243 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
# Turns a pushed version tag into a finished release, built on GitHub and
# nowhere else: tests, builds NVENCForgeGUI.exe with "wails build" and
# publishes it as the release asset. The asset name stays NVENCForgeGUI.exe,
# because the window's own update button looks for exactly that file.
#
# Two ways in:
# - push a tag vX.Y.Z (equal to guiVersion in main.go), or
# - Actions > Release > Run workflow with an existing tag.
# The release text is .github/release-notes/vX.Y.Z.md if that file exists,
# otherwise the message of the annotated tag (git tag -a).
#
# Dry run: tick "dry run" when starting it by hand. The exe is built and
# checked and stays on the run page as a workflow artifact; nothing is
# signed or published. Pull requests that change this workflow, wails.json,
# build/windows/ or .signpath/ get a dry run by themselves.
#
# Code signing (SignPath Foundation) is prepared but off until the
# repository has the variable SIGNPATH_ORGANIZATION_ID and the secret
# SIGNPATH_API_TOKEN; see .signpath/README.md. Without them the exe is
# released unsigned.
name: Release

on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to release (for example v2.2.0)'
required: true
dry_run:
description: 'Dry run: build and check only, sign and publish nothing'
type: boolean
default: false
pull_request:
paths:
- '.github/workflows/release.yml'
- '.signpath/**'
- 'wails.json'
- 'build/windows/**'

# Publishing needs write access to the repository contents; actions: read
# lets SignPath fetch the unsigned exe from this run.
permissions:
contents: write
actions: read

env:
DRY_RUN: ${{ github.event_name == 'pull_request' || inputs.dry_run == true }}

jobs:
release:
runs-on: windows-latest
# Includes up to one hour of waiting for the signing approval.
timeout-minutes: 120

steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
# The annotated tag message can be the release text, and it only
# exists in a clone that carries the tag objects.
fetch-depth: 0

- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

# Node only runs the window checks; the interface itself has no build step.
- uses: actions/setup-node@v4
with:
node-version: '22'

# A tag that disagrees with guiVersion would publish an exe that keeps
# offering itself as an update. A pull request has no tag: it builds
# the version in main.go.
- name: Tag matches guiVersion
run: |
$match = Select-String -Path main.go -Pattern '^const guiVersion = "(.+)"' | Select-Object -First 1
if (-not $match) {
Write-Host "::error::Could not find guiVersion in main.go"
exit 1
}
$inCode = $match.Matches[0].Groups[1].Value
$tag = "${{ inputs.tag || github.ref_name }}"
if ($env:GITHUB_EVENT_NAME -eq 'pull_request') { $tag = "v$inCode" }
if ("v$inCode" -ne $tag) {
Write-Host "::error::Tag $tag does not match guiVersion $inCode in main.go"
exit 1
}
Write-Host "Tag $tag matches guiVersion $inCode."
"TAG=$tag" | Out-File $env:GITHUB_ENV -Append -Encoding utf8
"VERSION=$inCode" | Out-File $env:GITHUB_ENV -Append -Encoding utf8

- name: go test
run: go test ./...

- name: window checks
run: |
$checks = Get-ChildItem -Path frontend\*_check.js | Sort-Object Name
if (-not $checks) {
Write-Host "::error::No frontend\*_check.js found - nothing was checked."
exit 1
}
$failed = @()
foreach ($check in $checks) {
node $check.FullName
if ($LASTEXITCODE -ne 0) { $failed += $check.Name }
}
if ($failed) {
Write-Host "::error::These window checks failed: $($failed -join ', ')"
exit 1
}

# wails.json feeds the file properties (right-click > Properties >
# Details); SignPath only signs an exe whose properties name
# NVENCForgeGUI and the version of the release.
- name: Version into wails.json
run: |
$text = [IO.File]::ReadAllText("$PWD\wails.json")
$text = [regex]::Replace($text, '"productVersion": "[^"]*"', "`"productVersion`": `"$env:VERSION`"")
[IO.File]::WriteAllText("$PWD\wails.json", $text, [Text.UTF8Encoding]::new($false))

# The Wails CLI of the same version as in go.mod; "go run" needs no
# global install.
- name: Build
run: |
go run github.com/wailsapp/wails/v2/cmd/wails@v2.14.0 build -trimpath
if ($LASTEXITCODE -ne 0) { exit 1 }
Copy-Item build/bin/NVENCForgeGUI.exe NVENCForgeGUI.exe

- name: Check the file properties
run: ./.signpath/check-files.ps1 -ProductName NVENCForgeGUI -ProductVersion $env:VERSION -Path NVENCForgeGUI.exe

# --- code signing ------------------------------------------------------
- name: 'code signing: on or off?'
id: signing
env:
ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
HAS_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
run: |
$hasOrganization = -not [string]::IsNullOrWhiteSpace($env:ORGANIZATION_ID)
$hasToken = $env:HAS_TOKEN -eq 'true'
if ($env:DRY_RUN -eq 'true') {
$on = $false
Write-Host 'Dry run: nothing is signed.'
} elseif ($hasOrganization -ne $hasToken) {
Write-Host '::error::Code signing is half set up: SIGNPATH_ORGANIZATION_ID and SIGNPATH_API_TOKEN must both be set, or neither (see .signpath/README.md).'
exit 1
} else {
$on = $hasOrganization -and $hasToken
Write-Host ($(if ($on) { 'Code signing is on.' } else { 'Code signing is off: this release is unsigned.' }))
}
"enabled=$($on.ToString().ToLower())" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8

- name: 'code signing: collect the exe'
if: steps.signing.outputs.enabled == 'true'
run: |
New-Item -ItemType Directory sign/unsigned -Force | Out-Null
Copy-Item NVENCForgeGUI.exe sign/unsigned/

- name: 'code signing: upload the exe'
id: unsigned
if: steps.signing.outputs.enabled == 'true'
uses: actions/upload-artifact@v7
with:
name: unsigned
path: sign/unsigned
if-no-files-found: error

- name: 'code signing: sign (approve the request in SignPath)'
if: steps.signing.outputs.enabled == 'true'
uses: signpath/github-action-submit-signing-request@v3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG || 'NVENCForgeGUI' }}
signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG || 'release-signing' }}
artifact-configuration-slug: ${{ vars.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
wait-for-completion: true
wait-for-completion-timeout-in-seconds: 3600
output-artifact-directory: sign/signed
parameters: |
version: "${{ env.VERSION }}"

- name: 'code signing: take over the signed exe'
if: steps.signing.outputs.enabled == 'true'
run: |
Copy-Item sign/signed/NVENCForgeGUI.exe NVENCForgeGUI.exe -Force
./.signpath/check-files.ps1 -ProductName NVENCForgeGUI -ProductVersion $env:VERSION -Path NVENCForgeGUI.exe -Signed

- name: 'dry run: keep the exe on the run page'
if: env.DRY_RUN == 'true'
uses: actions/upload-artifact@v7
with:
name: release-dry-run
path: NVENCForgeGUI.exe
retention-days: 7

# gh is preinstalled on GitHub runners and signs in with the token this
# workflow already holds, so no personal access token is involved.
- name: Publish release
if: env.DRY_RUN != 'true'
env:
GH_TOKEN: ${{ github.token }}
SIGNED: ${{ steps.signing.outputs.enabled }}
run: |
$tag = $env:TAG
$title = "NVENCForgeGUI $tag"
$file = ".github/release-notes/$tag.md"
if (Test-Path $file) {
$notes = [IO.File]::ReadAllText("$PWD/$file")
} else {
$notes = (git tag -l --format='%(contents)' $tag) -join "`n"
}
if ([string]::IsNullOrWhiteSpace($notes)) {
Write-Host "::error::No release text: add $file or create the tag with 'git tag -a'."
exit 1
}
# The release page is the download page, so it links the code
# signing policy and says whether this exe is signed.
$policy = "https://github.com/$env:GITHUB_REPOSITORY/blob/main/CODE_SIGNING.md"
if ($env:SIGNED -eq 'true') {
$footer = "NVENCForgeGUI.exe is signed. Free code signing provided by [SignPath.io](https://about.signpath.io), certificate by [SignPath Foundation](https://signpath.org). [Code signing policy]($policy)"
} else {
$footer = "NVENCForgeGUI.exe is not code signed, so Windows SmartScreen may warn. [Code signing policy]($policy)"
}
[IO.File]::WriteAllText("$PWD/release-notes.md", $notes.TrimEnd() + "`n`n---`n`n$footer`n", [Text.UTF8Encoding]::new($false))

Write-Host "Publishing $tag with NVENCForgeGUI.exe ($((Get-Item NVENCForgeGUI.exe).Length) bytes)."
# A rerun (a corrected workflow, a retried build) refreshes the
# release that is there instead of failing on the second attempt.
gh release view $tag *> $null
if ($LASTEXITCODE -eq 0) {
Write-Host "Release $tag already exists - refreshing its notes and asset."
gh release edit $tag --title $title --notes-file release-notes.md --draft=false
gh release upload $tag NVENCForgeGUI.exe --clobber
} else {
gh release create $tag NVENCForgeGUI.exe --title $title --notes-file release-notes.md
}
65 changes: 65 additions & 0 deletions .signpath/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Code signing with SignPath: prepared, off until switched on

NVENCForgeGUI has applied to the [SignPath Foundation](https://signpath.org) for free
code signing of its Windows files. Everything for it is prepared here and in
[`release.yml`](../.github/workflows/release.yml), but **it stays off** until it is
switched on below. Until then every release is built and published exactly as before,
unsigned, and its release text says so.

| File | What it is |
| ---- | ---------- |
| [`../CODE_SIGNING.md`](../CODE_SIGNING.md) | The public code signing policy (required by SignPath Foundation): roles, what is signed, privacy |
| [`artifact-configuration.xml`](artifact-configuration.xml) | The SignPath artifact configuration (the project's default): signs NVENCForgeGUI.exe only |
| [`check-files.ps1`](check-files.ps1) | Run by every release build: checks product name and version of every file to be signed (SignPath enforces both), and with `-Signed` that the signatures are valid |

## How the release workflow decides

| Repository settings | What a release does |
| ------------------- | ------------------- |
| Neither `SIGNPATH_ORGANIZATION_ID` nor `SIGNPATH_API_TOKEN` set | Builds and publishes unsigned, as before |
| Both set | Uploads the files, sends a signing request to SignPath, **waits up to one hour for the approval**, downloads the signed files, checks their signatures and publishes them |
| Only one of them set | Stops with an error, so a half finished setup never ships unsigned files by surprise |

**Dry run:** *Actions > Release > Run workflow* with "dry run" ticked builds and checks
everything, keeps the files on the run page as a workflow artifact, never signs and
publishes nothing. It also runs by itself for every pull request that changes the
release workflow or this folder.

## Switching it on (after SignPath Foundation said yes)

1. **Two-factor authentication** on GitHub and on SignPath for everybody in the team
(a condition of SignPath Foundation).
2. **In SignPath** (the Foundation sets up most of it with you):
- the project, slug `NVENCForgeGUI` (another slug: set the variable `SIGNPATH_PROJECT_SLUG`),
- the trusted build system *GitHub.com* linked to the project, and the SignPath
GitHub App installed on this repository (SignPath shows the link),
- the artifact configuration from [`artifact-configuration.xml`](artifact-configuration.xml), set as the project's default,
- the signing policy `release-signing` with manual approval by the approver
(another slug: set `SIGNPATH_SIGNING_POLICY_SLUG`),
- a CI user with the *Submitter* role on that policy, and an API token for it.
3. **On GitHub:** *Settings > Secrets and variables > Actions*
- secret `SIGNPATH_API_TOKEN`: the API token of the CI user,
- variable `SIGNPATH_ORGANIZATION_ID`: the organization ID from SignPath.
4. **Recommended:** a ruleset that protects `main` and the `v*` tags from force pushes
and deletion (*Settings > Rules > Rulesets*). SignPath can require this in its
policies.
5. **Update the texts:** the status line at the top of
[`CODE_SIGNING.md`](../CODE_SIGNING.md) and the SmartScreen note in the README.
6. **Release as usual.** Approve the signing request in SignPath within one hour (the
link is in the run log and in the e-mail from SignPath), then the release is published
with signed files.

| Variable / secret | Required | Default |
| ----------------- | -------- | ------- |
| `SIGNPATH_API_TOKEN` (secret) | yes | |
| `SIGNPATH_ORGANIZATION_ID` | yes | |
| `SIGNPATH_PROJECT_SLUG` | no | `NVENCForgeGUI` |
| `SIGNPATH_SIGNING_POLICY_SLUG` | no | `release-signing` |
| `SIGNPATH_ARTIFACT_CONFIGURATION_SLUG` | no | the project's default artifact configuration |

## Taking it out again

Never setting the secret and the variable is enough: nothing is signed then. To remove
it completely: delete this folder and `CODE_SIGNING.md`, remove the steps marked
`code signing` in `release.yml` (the file check step uses `check-files.ps1` from this
folder) and the links to `CODE_SIGNING.md` in the README.
21 changes: 21 additions & 0 deletions .signpath/artifact-configuration.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SignPath artifact configuration for NVENCForgeGUI (see
../.github/workflows/release.yml). Copy it into the SignPath project as
its default artifact configuration.

The workflow uploads NVENCForgeGUI.exe as a ZIP. Only this file is
signed, and only if its file properties name NVENCForgeGUI and the
version of the release (wails.json, written by the workflow).
-->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<parameters>
<!-- The version of the release, e.g. 2.1.0 (guiVersion in main.go). -->
<parameter name="version" required="true" />
</parameters>
<zip-file>
<pe-file path="NVENCForgeGUI.exe" product-name="NVENCForgeGUI" product-version="${version}">
<authenticode-sign />
</pe-file>
</zip-file>
</artifact-configuration>
61 changes: 61 additions & 0 deletions .signpath/check-files.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# NVENCForgeGUI (https://github.com/burnersen/NVENCForgeGUI)
# Copyright (C) 2026 burnersen
# SPDX-License-Identifier: GPL-3.0-only
#
# Checks the Windows files a release is about to sign or publish:
#
# - every file exists,
# - its file properties carry exactly the product name and product version
# that the SignPath artifact configuration enforces (product-name,
# product-version). SignPath refuses a signing request whose files do not
# match, so this check runs on every release build, signed or not: a
# mismatch shows up long before the first signed release.
# - with -Signed: every file carries a valid Authenticode signature.
#
# Usage (from the release workflow):
# ./.signpath/check-files.ps1 -ProductName X -ProductVersion 1.2.3 -Path a.exe, b.exe [-Signed]

param(
[Parameter(Mandatory)] [string[]] $Path,
[Parameter(Mandatory)] [string] $ProductName,
[Parameter(Mandatory)] [string] $ProductVersion,
[switch] $Signed
)

$ErrorActionPreference = 'Stop'
$failed = 0

foreach ($file in $Path) {
if (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
Write-Host "::error::$file is missing"
$failed++
continue
}
$info = (Get-Item -LiteralPath $file).VersionInfo
$name = "$($info.ProductName)".Trim()
$version = "$($info.ProductVersion)".Trim()
if ($name -ne $ProductName) {
Write-Host "::error::$file has product name '$name', expected '$ProductName'"
$failed++
}
if ($version -ne $ProductVersion) {
Write-Host "::error::$file has product version '$version', expected '$ProductVersion'"
$failed++
}
$line = "{0}: {1} {2}" -f $file, $name, $version
if ($Signed) {
$signature = Get-AuthenticodeSignature -LiteralPath $file
if ($signature.Status -ne 'Valid') {
Write-Host "::error::$file is not validly signed ($($signature.Status): $($signature.StatusMessage))"
$failed++
} else {
$line += ", signed by $($signature.SignerCertificate.Subject)"
}
}
Write-Host $line
}

if ($failed -gt 0) {
Write-Host "::error::$failed problem(s) with the release files"
exit 1
}
Loading
Loading