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
125 changes: 120 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
# release asset. Runs on Windows for the same reason CI does - every source
# file carries //go:build windows && amd64, so a Linux runner finds no
# packages at all and would report success without building anything.
#
# 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, winres/ 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, exactly as before.
name: Release

on:
Expand All @@ -16,15 +26,31 @@ on:
tag:
description: 'Existing tag to release (for example v1.25.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/**'
- 'winres/**'

# The default token is read-only; publishing a release and uploading its asset
# both need write access to the repository contents.
# both need 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
Expand All @@ -46,38 +72,119 @@ jobs:

# A tag that disagrees with appVersion would ship an exe announcing the
# wrong version in its own startup header. Cheaper to catch here than to
# replace a published asset afterwards.
# replace a published asset afterwards. A pull request has no tag: it
# builds the version in main.go.
- name: Tag matches appVersion
run: |
$tag = "${{ inputs.tag || github.ref_name }}"
$match = Select-String -Path main.go -Pattern '^const appVersion = "(.+)"' | Select-Object -First 1
if (-not $match) {
Write-Host "::error::Could not find appVersion 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 appVersion $inCode in main.go"
exit 1
}
Write-Host "Tag $tag matches appVersion $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 and version in the exe's file properties (right-click >
# Properties > Details). SignPath only signs an exe whose properties
# name NVENCForge and the version of the release.
- name: File properties
run: go run github.com/tc-hib/[email protected] make --in winres/winres.json --out rsrc --arch amd64 --product-version $env:VERSION --file-version $env:VERSION

# -ldflags="-s -w" drops the debug and symbol tables: the same flags
# every released exe so far was built with, so the asset stays the size
# users are used to.
- name: Build
run: go build -ldflags="-s -w" -o NVENCForge.exe .

- name: Check the file properties
run: ./.signpath/check-files.ps1 -ProductName NVENCForge -ProductVersion $env:VERSION -Path NVENCForge.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 NVENCForge.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 || 'NVENCForge' }}
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/NVENCForge.exe NVENCForge.exe -Force
./.signpath/check-files.ps1 -ProductName NVENCForge -ProductVersion $env:VERSION -Path NVENCForge.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: NVENCForge.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 = "${{ inputs.tag || github.ref_name }}"
$tag = $env:TAG
$title = "NVENCForge $tag"
Write-Host "Publishing $tag with NVENCForge.exe ($((Get-Item NVENCForge.exe).Length) bytes)."

Expand All @@ -90,7 +197,15 @@ jobs:
Write-Host "::error::Tag $tag has no annotation - the release notes come from it. Create it with 'git tag -a'."
exit 1
}
$notes | Out-File -FilePath release-notes.md -Encoding utf8
# 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 = "NVENCForge.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 = "NVENCForge.exe is not code signed, so Windows SmartScreen may warn. [Code signing policy]($policy)"
}
"$notes`n`n---`n`n$footer" | Out-File -FilePath release-notes.md -Encoding utf8

# gh release view exits non-zero when the release does not exist yet.
# Re-running a release (a corrected workflow, a retried build) has to
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ SRTCleaner_config.txt
output/
NVENCForge_Config.ini.bak
NVENCForge_Config.ini.bak-*

# Windows resources (version info), generated by the release workflow
# from winres/winres.json.
*.syso
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

NVENCForge 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 NVENCForge.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 `NVENCForge` (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 | `NVENCForge` |
| `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.
22 changes: 22 additions & 0 deletions .signpath/artifact-configuration.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SignPath artifact configuration for NVENCForge (see
../.github/workflows/release.yml). Copy it into the SignPath project as
its default artifact configuration.

The workflow uploads NVENCForge.exe as a ZIP. Only this file is signed,
and only if its file properties name NVENCForge and the version of the
release. FFmpeg is never part of a release: NVENCForge downloads it on
first run.
-->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<parameters>
<!-- The version of the release, e.g. 2.2.1 (appVersion in main.go). -->
<parameter name="version" required="true" />
</parameters>
<zip-file>
<pe-file path="NVENCForge.exe" product-name="NVENCForge" 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 @@
# NVENCForge (https://github.com/burnersen/NVENCForge)
# 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