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
133 changes: 125 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,56 @@
# publishes everything together. Nothing is published if any step fails.
#
# The release text is the backup part written by release-assets.ps1,
# followed by .github/release-notes/<version>.md (what's new).
# followed by .github/release-notes/<version>.md (what's new) and a line
# that links the code signing policy.
#
# Dry run: tick "dry run" when starting it by hand (any branch). Everything
# is collected, tested, built and checked, CryptBridge.exe stays on the run
# page as a workflow artifact, nothing is signed, tagged or published. Pull
# requests that change this workflow, the build scripts 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 CryptBridge.exe
# is released unsigned, exactly as before. Only CryptBridge.exe is ever
# signed: the rclone and WinFsp copies stay exactly as their projects
# published them.

name: Release

on:
push:
tags: ['v*.*.*']
workflow_dispatch:
inputs:
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/**'
- 'windows/build.ps1'
- 'windows/release-assets.ps1'

permissions:
contents: write
actions: read

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

concurrency:
group: release
# Dry runs never wait for (or hold up) a real release.
group: ${{ (github.event_name == 'pull_request' || inputs.dry_run == true) && format('release-dry-run-{0}', github.ref) || 'release' }}

jobs:
release:
runs-on: windows-latest
timeout-minutes: 60
# Includes up to one hour of waiting for the signing approval.
timeout-minutes: 120
defaults:
run:
shell: pwsh
Expand All @@ -42,16 +73,22 @@ jobs:
run: |
$version = (Get-Content VERSION -Raw).Trim()
$tag = "v$version"
$dryRun = $env:DRY_RUN -eq 'true'
if ($env:GITHUB_REF_TYPE -eq 'tag') {
if ($tag -ne $env:GITHUB_REF_NAME) { throw "tag $env:GITHUB_REF_NAME does not match VERSION $version" }
} elseif ($env:GITHUB_REF_NAME -ne 'main') {
} elseif ($env:GITHUB_REF_NAME -ne 'main' -and -not $dryRun) {
throw "a release is made from main only, not from $env:GITHUB_REF_NAME"
}
gh release view $tag 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) { throw "release $tag exists already: raise VERSION first" }
if (-not $dryRun) {
gh release view $tag 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) { throw "release $tag exists already: raise VERSION first" }
}
$global:LASTEXITCODE = 0
$notes = ".github/release-notes/$version.md"
if (-not (Test-Path $notes)) { throw "$notes is missing" }
if (-not (Test-Path $notes)) {
if (-not $dryRun) { throw "$notes is missing" }
Write-Host "::warning::$notes is missing (fine for a dry run)"
}
"VERSION=$version" | Out-File $env:GITHUB_ENV -Append -Encoding utf8
"TAG=$tag" | Out-File $env:GITHUB_ENV -Append -Encoding utf8

Expand All @@ -74,14 +111,94 @@ jobs:
- name: Test and build CryptBridge.exe
run: ./windows/build.ps1

# SignPath only signs an exe whose file properties name CryptBridge
# and the version of the release (build.ps1 writes both from VERSION).
- name: Check the file properties
run: ./.signpath/check-files.ps1 -ProductName CryptBridge -ProductVersion $env:VERSION -Path windows/build/bin/CryptBridge.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) {
throw 'Code signing is half set up: SIGNPATH_ORGANIZATION_ID and SIGNPATH_API_TOKEN must both be set, or neither (see .signpath/README.md).'
} 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 windows/build/bin/CryptBridge.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 || 'CryptBridge' }}
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/CryptBridge.exe windows/build/bin/CryptBridge.exe -Force
./.signpath/check-files.ps1 -ProductName CryptBridge -ProductVersion $env:VERSION -Path windows/build/bin/CryptBridge.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: windows/build/bin/CryptBridge.exe
retention-days: 7

- name: Publish the release
if: env.DRY_RUN != 'true'
env:
GH_TOKEN: ${{ github.token }}
SIGNED: ${{ steps.signing.outputs.enabled }}
run: |
$release = 'windows/build/bin/release'
$notes = Join-Path $env:RUNNER_TEMP 'release-notes.md'
# The release page is the download page, so it links the code
# signing policy and says whether CryptBridge.exe is signed.
$policy = "https://github.com/$env:GITHUB_REPOSITORY/blob/main/CODE_SIGNING.md"
if ($env:SIGNED -eq 'true') {
$footer = "CryptBridge.exe is signed. Free code signing provided by [SignPath.io](https://about.signpath.io), certificate by [SignPath Foundation](https://signpath.org). The rclone and WinFsp copies are unchanged originals. [Code signing policy]($policy)"
} else {
$footer = "CryptBridge.exe is not code signed, so Windows SmartScreen may warn. [Code signing policy]($policy)"
}
$text = [IO.File]::ReadAllText("$release/release-notes.md") + "`n" +
[IO.File]::ReadAllText(".github/release-notes/$env:VERSION.md")
[IO.File]::ReadAllText(".github/release-notes/$env:VERSION.md").TrimEnd() + "`n`n---`n`n$footer`n"
[IO.File]::WriteAllText($notes, $text, [Text.UTF8Encoding]::new($false))

$files = @('windows/build/bin/CryptBridge.exe', 'linux/setup.sh')
Expand Down
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

CryptBridge 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 CryptBridge.exe only, never the rclone or WinFsp copies |
| [`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 `CryptBridge` (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 | `CryptBridge` |
| `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 CryptBridge (see
../.github/workflows/release.yml). Copy it into the SignPath project as
its default artifact configuration.

The workflow uploads CryptBridge.exe alone as a ZIP. Only this file is
signed, and only if its file properties name CryptBridge and the version
of the release. The rclone and WinFsp copies of a release are never sent
to SignPath: they stay the unchanged originals of their projects.
-->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<parameters>
<!-- The version of the release, e.g. 0.12.0 (the file VERSION). -->
<parameter name="version" required="true" />
</parameters>
<zip-file>
<pe-file path="CryptBridge.exe" product-name="CryptBridge" 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 @@
# CryptBridge (https://github.com/burnersen/CryptBridge)
# 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