diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ed45fde..8b27495 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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:
@@ -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
@@ -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/go-winres@v0.3.3 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)."
@@ -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
diff --git a/.gitignore b/.gitignore
index 6ba31bd..d8643bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/.signpath/README.md b/.signpath/README.md
new file mode 100644
index 0000000..3bb3afb
--- /dev/null
+++ b/.signpath/README.md
@@ -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.
diff --git a/.signpath/artifact-configuration.xml b/.signpath/artifact-configuration.xml
new file mode 100644
index 0000000..d28a79e
--- /dev/null
+++ b/.signpath/artifact-configuration.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.signpath/check-files.ps1 b/.signpath/check-files.ps1
new file mode 100644
index 0000000..9cad969
--- /dev/null
+++ b/.signpath/check-files.ps1
@@ -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
+}
diff --git a/CODE_SIGNING.md b/CODE_SIGNING.md
new file mode 100644
index 0000000..93dc41e
--- /dev/null
+++ b/CODE_SIGNING.md
@@ -0,0 +1,72 @@
+# Code signing policy
+
+Free code signing provided by [SignPath.io](https://about.signpath.io),
+certificate by [SignPath Foundation](https://signpath.org).
+
+> **Status:** NVENCForge has applied to the SignPath Foundation. Until the
+> signing is active, `NVENCForge.exe` is not signed. Every release says in
+> its text whether its exe is signed.
+
+## What is signed
+
+Only `NVENCForge.exe`, built from the source code in this repository by the
+[release workflow](.github/workflows/release.yml) on GitHub-hosted runners.
+Nothing that was built elsewhere is signed with this certificate. Every
+signing request is approved by hand, release by release.
+
+FFmpeg is not part of NVENCForge and is never signed by this project: on
+first run NVENCForge downloads an official build of the
+[BtbN FFmpeg-Builds](https://github.com/BtbN/FFmpeg-Builds) project, or uses
+the `ffmpeg.exe` you put next to it.
+
+The file properties of the signed exe name the product `NVENCForge` and the
+version of the release; SignPath refuses to sign a file that does not match.
+
+## Team roles
+
+| Role | Who |
+| ---- | --- |
+| Committers and reviewers | [burnersen](https://github.com/burnersen) |
+| Approvers | [burnersen](https://github.com/burnersen) |
+
+Changes from anybody else (pull requests) are reviewed by a committer before
+they are merged. All team members use multi-factor authentication for GitHub
+and for SignPath.
+
+## Privacy policy
+
+This program will not transfer any information to other networked systems
+unless specifically requested by the user or the person installing or
+operating it.
+
+What NVENCForge does on the network, and only for that:
+
+- **FFmpeg download:** only if there is no `ffmpeg.exe` / `ffprobe.exe` next
+ to the exe, NVENCForge asks the GitHub API for the newest release of
+ [BtbN/FFmpeg-Builds](https://github.com/BtbN/FFmpeg-Builds) and downloads
+ it from GitHub. Nothing else is sent.
+
+Like any network request, this reveals your IP address to GitHub. Your
+videos are converted on your own PC and never leave it. There is no
+telemetry, no update check, no account and no advertising.
+
+## Installing and uninstalling
+
+There is no installer: NVENCForge is one exe. It keeps its settings
+(`NVENCForge_Config.ini`), the downloaded FFmpeg and its log next to that exe
+and writes nothing to the registry. To uninstall, delete the folder. If you
+added NVENCForge to the "Send to" menu, also delete that shortcut
+(`Win+R`, `shell:sendto`).
+
+## Verifying a download
+
+- **Signature:** right-click `NVENCForge.exe` > *Properties* > *Digital
+ Signatures*. A signed release shows *SignPath Foundation*.
+- **Build it yourself:** see [TECHNICAL.md](TECHNICAL.md#building).
+
+## Reporting a problem
+
+If you think a signed file of this project does something it should not,
+please open an [issue](https://github.com/burnersen/NVENCForge/issues).
+Signed files that break these rules can also be reported to the
+[SignPath Foundation](https://signpath.org).
diff --git a/README.md b/README.md
index b06af97..404ba4e 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ HDR-aware. Resilient. DaVinci-Resolve-ready. One EXE.
[](#-license)
[](https://ko-fi.com/burnersen)
-**[โฌ๏ธ Download the latest release](https://github.com/burnersen/NVENCForge/releases/latest)** ยท **[๐ Website](https://tech.burnersen.de/en/tools/nvencforge-en/)** ยท **[โ Buy me a coffee](https://ko-fi.com/burnersen)**
+**[โฌ๏ธ Download the latest release](https://github.com/burnersen/NVENCForge/releases/latest)** ยท **[๐ Website](https://tech.burnersen.de/en/tools/nvencforge-en/)** ยท **[๐ Code signing policy](CODE_SIGNING.md)** ยท **[โ Buy me a coffee](https://ko-fi.com/burnersen)**
๐ฉ๐ช Auf Deutsch: [tech.burnersen.de/tools/nvencforge](https://tech.burnersen.de/tools/nvencforge/)
@@ -315,7 +315,9 @@ The five worth knowing about:
## ๐ก๏ธ Windows SmartScreen / antivirus warnings
-Windows or your antivirus may warn you the first time you run `NVENCForge.exe`. The honest reason: **the EXE is not code-signed.** Signing certificates cost several hundred euros *per year*, and this is a free hobby project with zero income. Unsigned Go binaries are frequent false-positive targets; there is nothing I can do about it except be transparent.
+Windows or your antivirus may warn you the first time you run `NVENCForge.exe`. The honest reason: **the EXE is not code-signed yet.** Signing certificates cost several hundred euros *per year*, and this is a free hobby project with zero income. Free code signing through the [SignPath Foundation](https://signpath.org) is applied for and already prepared; see the [code signing policy](CODE_SIGNING.md). Until then, unsigned Go binaries are frequent false-positive targets, and all I can do is be transparent.
+
+Every release is built by GitHub Actions from this repository ([release workflow](.github/workflows/release.yml)), never on a private PC.
You don't have to trust me blindly: **scan it** on [VirusTotal](https://www.virustotal.com), **read it** โ the complete source is in this repository โ or **[build it yourself](TECHNICAL.md#building)**.
@@ -336,7 +338,10 @@ NVENCForge is free software under the [GNU General Public License, version 3](LI
- **Use it:** Anyone may use NVENCForge free of charge, at home or at work, and read, change and share its source code.
- **The one condition:** Whoever passes on NVENCForge or parts of it โ unchanged, changed or built into a program of their own โ must include the complete source code and pass everything on under the same license. A closed, paid product with NVENCForge inside is therefore not possible.
- **Free of charge:** NVENCForge is and stays free. The official source is [github.com/burnersen/NVENCForge](https://github.com/burnersen/NVENCForge) โ anyone asking money for NVENCForge is selling you what you can get here for free.
-- **A separate license for companies:** Want to build NVENCForge into a product that is not meant to be under the GPLv3? You can ask the author for a separate license โ just [open an issue](https://github.com/burnersen/NVENCForge/issues).
+
+### Code signing and privacy
+
+What is signed, who approves it and what NVENCForge sends over the network (only the FFmpeg download): [code signing policy](CODE_SIGNING.md). To uninstall, delete the folder; nothing is written to the registry.
### FFmpeg
diff --git a/TECHNICAL.md b/TECHNICAL.md
index d388812..258b04c 100644
--- a/TECHNICAL.md
+++ b/TECHNICAL.md
@@ -393,6 +393,12 @@ build.bat
That's it. `build.bat` resolves the dependencies and compiles `NVENCForge.exe` (Go 1.21+).
+Released exes are built by the [release workflow](.github/workflows/release.yml) on GitHub, which also writes name and version into the file properties (from [`winres/winres.json`](winres/winres.json)) and, once code signing is switched on, has the exe signed: see [`.signpath/README.md`](.signpath/README.md). To get the same file properties locally, run this before `build.bat`:
+
+```
+go run github.com/tc-hib/go-winres@v0.3.3 make --in winres/winres.json --out rsrc --arch amd64 --product-version 2.2.1 --file-version 2.2.1
+```
+
---
**[โ Back to the README](README.md)**
diff --git a/winres/winres.json b/winres/winres.json
new file mode 100644
index 0000000..0b7f797
--- /dev/null
+++ b/winres/winres.json
@@ -0,0 +1,22 @@
+{
+ "RT_VERSION": {
+ "#1": {
+ "0000": {
+ "fixed": {
+ "file_version": "0.0.0.0",
+ "product_version": "0.0.0.0"
+ },
+ "info": {
+ "0409": {
+ "CompanyName": "burnersen",
+ "FileDescription": "NVENCForge - H.265 / AV1 NVENC batch encoder",
+ "InternalName": "NVENCForge",
+ "LegalCopyright": "Copyright (C) 2026 burnersen, GPL-3.0-only",
+ "OriginalFilename": "NVENCForge.exe",
+ "ProductName": "NVENCForge"
+ }
+ }
+ }
+ }
+ }
+}