Skip to content
Open
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
78 changes: 78 additions & 0 deletions .github/actions/sign/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# The single seam for release code signing. The provider today is
# SignPath Foundation; the planned migration to Azure Artifact Signing
# (or an OV certificate on a cloud HSM) replaces the internals of this
# action without touching the release workflow. See docs/code-signing.md.

name: Sign release binaries
description: >-
Sends the unsigned release artifact to the code-signing provider and
places the signed files in the output directory. When the provider
credentials are not configured, the binaries pass through unsigned
with a workflow warning.

inputs:
github-artifact-id:
description: >-
Id of the unsigned artifact uploaded with actions/upload-artifact
(steps.<id>.outputs.artifact-id).
required: true
github-artifact-name:
description: >-
Name of the same artifact, used by the unsigned pass-through.
required: true
output-directory:
description: Directory where the signed files are placed.
required: true
signpath-api-token:
description: SignPath REST API token. Leave empty to skip signing.
required: false
default: ''
signpath-organization-id:
description: SignPath organization id.
required: false
default: ''
signpath-project-slug:
description: SignPath project slug.
required: false
default: ''
signpath-signing-policy-slug:
description: SignPath signing policy slug.
required: false
default: 'release-signing'

outputs:
signed:
description: >-
"true" when the binaries were signed, "false" when the unsigned
pass-through was taken.
value: ${{ inputs.signpath-api-token != '' }}

runs:
using: composite
steps:
- name: Submit the SignPath signing request
if: inputs.signpath-api-token != ''
uses: signpath/github-action-submit-signing-request@b9d91eadd323de506c0c81cf0c7fe7438f3360fd # v2.2
with:
api-token: ${{ inputs.signpath-api-token }}
organization-id: ${{ inputs.signpath-organization-id }}
project-slug: ${{ inputs.signpath-project-slug }}
signing-policy-slug: ${{ inputs.signpath-signing-policy-slug }}
github-artifact-id: ${{ inputs.github-artifact-id }}
wait-for-completion: true
# Release signing needs a manual approval in the SignPath UI,
# so leave ample time before giving up.
wait-for-completion-timeout-in-seconds: '3600'
output-artifact-directory: ${{ inputs.output-directory }}

- name: Warn that signing is skipped
if: inputs.signpath-api-token == ''
shell: pwsh
run: Write-Output "::warning::Signing credentials are not configured; the release binaries stay UNSIGNED. See docs/code-signing.md."

- name: Pass the unsigned binaries through
if: inputs.signpath-api-token == ''
uses: actions/download-artifact@v8
with:
name: ${{ inputs.github-artifact-name }}
path: ${{ inputs.output-directory }}
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,24 @@ jobs:
# The hosted image has VS with MSVC, so the RequiresVS and Publish
# suites run for real instead of skipping.
- run: dotnet test rbmanager.sln --no-build

# NativeAOT publish for both shipping targets; win-arm64 cross-compiles
# on the x64 runner. CI artifacts are unsigned; signing happens only in
# the release workflow (docs/code-signing.md).
publish:
runs-on: windows-latest
timeout-minutes: 30
strategy:
matrix:
rid: [win-x64, win-arm64]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- run: dotnet publish src/rbmanager -r ${{ matrix.rid }} -c Release -o artifacts/publish/${{ matrix.rid }}
- uses: actions/upload-artifact@v7
with:
name: rb-${{ matrix.rid }}-unsigned
path: artifacts/publish/${{ matrix.rid }}/rb.exe
if-no-files-found: error
119 changes: 119 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Builds rb.exe from a version tag, signs it, and attaches the binaries
# and their SHA-256 checksums to a GitHub release. Signing is described
# in docs/code-signing.md; when the signing credentials are not
# configured, the release is created as an unsigned draft instead.

name: Release

on:
push:
tags: ['v*']

permissions:
contents: read

Comment on lines +12 to +14
jobs:
build:
runs-on: windows-latest
timeout-minutes: 30
outputs:
artifact-id: ${{ steps.upload.outputs.artifact-id }}
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json

- name: Derive the version from the tag
id: version
shell: pwsh
run: |
"version=$('${{ github.ref_name }}'.TrimStart('v'))" >> $env:GITHUB_OUTPUT

- run: dotnet test rbmanager.sln

# win-arm64 cross-compiles on the x64 runner; the hosted image
# carries the ARM64 MSVC tools.
- name: Publish rb.exe for win-x64 and win-arm64
shell: pwsh
run: |
foreach ($rid in 'win-x64', 'win-arm64') {
dotnet publish src/rbmanager -r $rid -c Release `
-p:Version=${{ steps.version.outputs.version }} `
-o artifacts/publish/$rid
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
New-Item -ItemType Directory unsigned | Out-Null
Copy-Item artifacts/publish/win-x64/rb.exe unsigned/rb-x64.exe
Copy-Item artifacts/publish/win-arm64/rb.exe unsigned/rb-arm64.exe

- name: Upload the unsigned binaries
id: upload
uses: actions/upload-artifact@v7
with:
name: rb-unsigned
path: unsigned/
if-no-files-found: error

release:
needs: build
runs-on: windows-latest
timeout-minutes: 90
# The environment holds the signing credentials and can require a
# manual approval before the job starts.
environment: release-signing
permissions:
contents: write # create the release
actions: read # let the signing provider download the artifact
steps:
# The checkout only provides ./.github/actions/sign; the binaries
# come from the build job's artifact.
- uses: actions/checkout@v5

- name: Sign rb.exe
id: sign
uses: ./.github/actions/sign
with:
github-artifact-id: ${{ needs.build.outputs.artifact-id }}
github-artifact-name: rb-unsigned
output-directory: signed
signpath-api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
signpath-organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
signpath-project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
signpath-signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}

- name: Verify the Authenticode signatures
if: steps.sign.outputs.signed == 'true'
shell: pwsh
run: |
foreach ($exe in Get-ChildItem signed -Filter *.exe) {
$sig = Get-AuthenticodeSignature $exe.FullName
Write-Output "$($exe.Name): $($sig.Status) ($($sig.SignerCertificate.Subject))"
if ($sig.Status -ne 'Valid') { exit 1 }
}

- name: Generate the SHA-256 checksums
shell: pwsh
run: |
foreach ($exe in Get-ChildItem signed -Filter *.exe) {
$hash = (Get-FileHash $exe.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash *$($exe.Name)" | Out-File -Encoding ascii "$($exe.FullName).sha256"
}

- name: Create the GitHub release
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
$ghArgs = @('release', 'create', '${{ github.ref_name }}',
'--title', 'rbmanager ${{ needs.build.outputs.version }}',
'--generate-notes', '--verify-tag')
if ('${{ steps.sign.outputs.signed }}' -ne 'true') {
# Never publish unsigned binaries silently; leave the
# decision to a human.
$ghArgs += '--draft'
Write-Output "::warning::Created a DRAFT release with unsigned binaries."
}
$ghArgs += Get-ChildItem signed -File | ForEach-Object FullName
gh @ghArgs
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,29 @@ directory on the user PATH, producing exactly the layout `rb setup`
creates, and removes both again on uninstall. rbmanager is a single MSI
product line; see [docs/upgrade-code.md](docs/upgrade-code.md). WiX
5.0.2 comes in through the `WixToolset.Sdk` NuGet package, and ICE
validation runs as part of the build. The output is unsigned; code
signing is tracked separately.
validation runs as part of the build. The MSI is currently unsigned;
only the release rb.exe is signed (see below).

An earlier iteration packaged each Ruby version as its own MSI. That
direction was dropped in favor of rbmanager; see the git history for
the sources and the verification record.

## Releases and code signing

Pushing a `v*` tag runs `.github/workflows/release.yml`, which builds
NativeAOT rb.exe for win-x64 and win-arm64, signs both, and attaches
them with `.sha256` checksums to a GitHub release. rb.exe is the one
binary users download directly with a browser, so it faces SmartScreen
with the Mark of the Web attached; it is the first signing target,
ahead of the MSI and winget channels. CI builds stay unsigned.

Free code signing provided by [SignPath.io](https://signpath.io),
certificate by [SignPath Foundation](https://signpath.org). The
signing step is isolated in `.github/actions/sign` so the provider can
be replaced later; the signing policy, the repository configuration,
and the SignPath application checklist are in
[docs/code-signing.md](docs/code-signing.md).

## CA trust bootstrap

The vcpkg-built OpenSSL in the binary packages has no usable trust
Expand Down
117 changes: 117 additions & 0 deletions docs/code-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Code signing

This document is the code signing policy for rbmanager and the
operating manual for the release signing pipeline.

## What is signed, and why rb.exe first

The only signed artifacts are the release builds of `rb.exe`
(`rb-x64.exe` and `rb-arm64.exe` on the GitHub release page). They are
built by `.github/workflows/release.yml` from a version tag of
https://github.com/ruby/rbmanager, and nothing that was not built from
this repository's source is ever submitted for signing.

rb.exe is the one binary users download directly with a browser, so it
carries the Mark of the Web and faces SmartScreen head-on; unsigned, it
is effectively blocked by the "Windows protected your PC" dialog. That
makes it the highest-priority signing target of the whole distribution
chain, ahead of the MSI and winget channels. CI builds and anything
not built from a tag stay unsigned.

## Provider: SignPath Foundation now, replaceable later

Signing starts on the free open-source tier of
[SignPath Foundation](https://signpath.org). Consequences to be aware
of:

- The certificate subject is "SignPath Foundation", not a Ruby entity.
- Every signing request requires a manual approval in the SignPath UI.
- All team members with access to the SignPath project need MFA.
- Only artifacts built from this repository's source may be signed.
- The project must publish this policy and the attribution "Free code
signing provided by SignPath.io, certificate by SignPath Foundation"
(see README).
- Timestamping (RFC 3161) is applied by the platform.

The plan is to migrate to Azure Trusted Signing (now Artifact Signing)
or an OV certificate on a cloud HSM once either becomes practical;
Azure's Public Trust identity validation is currently limited to
US/CA/EU/UK legal entities, which is why SignPath goes first. To keep
the migration cheap, all provider-specific logic lives in the
composite action `.github/actions/sign`. The release workflow only
ever sees "unsigned artifact in, signed files out", so swapping the
provider means editing that one action.

## How the release pipeline works

Pushing a `v*` tag runs `.github/workflows/release.yml`:

1. The build job runs the test suite, publishes NativeAOT `rb.exe` for
win-x64 and win-arm64 with `-p:Version=<tag>`, and uploads both as
one unsigned artifact.
2. The release job runs in the `release-signing` environment, which
holds the credentials and can require a reviewer approval before
the job starts.
3. `.github/actions/sign` submits the artifact to SignPath and waits
(up to an hour) for the manual approval there.
4. The signatures are verified with `Get-AuthenticodeSignature`; any
status other than `Valid` fails the release.
5. `.sha256` checksum files are generated next to the binaries and
everything is attached to a GitHub release created from the tag.

When the SignPath credentials are not configured, the signing step is
skipped with a workflow warning and the release is created as a draft
so unsigned binaries are never published silently.

The exe's VERSIONINFO (ProductName `rbmanager`, FileDescription,
company, version) is set in `src/rbmanager/rbmanager.csproj`; SignPath
uses it to check that the artifact matches the project.

## Repository configuration

Everything lives in the `release-signing` GitHub environment
(Settings, Environments). Recommended protection: required reviewers,
so a human gates every signing run.

Secret (environment-scoped, only the release workflow can read it):

- `SIGNPATH_API_TOKEN`: API token of a SignPath user with submitter
permission.

Variables (environment or repository level):

- `SIGNPATH_ORGANIZATION_ID`
- `SIGNPATH_PROJECT_SLUG`
- `SIGNPATH_SIGNING_POLICY_SLUG` (defaults to `release-signing` when
unset in the sign action; set it if the SignPath policy is named
differently)

Until these exist, tag pushes still work and produce unsigned draft
releases.

## SignPath Foundation application checklist

Registering the project with SignPath Foundation is a manual step.
Information the application needs:

- Repository URL: https://github.com/ruby/rbmanager
- License: BSD-2-Clause (see `LICENSE`)
- Artifact description: `rb.exe`, a self-contained .NET 8 NativeAOT
Windows executable (the `rb` command of the rbmanager Ruby version
manager), built for win-x64 and win-arm64 by GitHub Actions in this
repository.
- Code signing policy: this document.
- Attribution: present in the README.

SignPath-side setup after approval:

1. Install the SignPath GitHub App on the repository and link GitHub
Actions as a trusted build system to the project.
2. Create an artifact configuration for a zip container holding
`rb-x64.exe` and `rb-arm64.exe`, both as Authenticode-signed PE
files (this matches the `rb-unsigned` artifact the build job
uploads).
3. Create a signing policy for release signing with manual approval
and note its slug in `SIGNPATH_SIGNING_POLICY_SLUG`.
4. Create an API token for a CI user with submitter permission and
store it as the `SIGNPATH_API_TOKEN` secret.
12 changes: 12 additions & 0 deletions src/rbmanager/rbmanager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

<!-- VERSIONINFO on the shipped exe. SmartScreen and the file-properties
dialog show these fields, and the code-signing provider requires
them to identify the project (docs/code-signing.md). The release
workflow overrides Version from the tag. -->
<PropertyGroup>
<Version>0.1.0</Version>
<Product>rbmanager</Product>
<AssemblyTitle>Ruby version manager for Windows</AssemblyTitle>
<Company>Ruby Core Team</Company>
<Copyright>Copyright (c) 2026 Hiroshi SHIBATA</Copyright>
</PropertyGroup>

<ItemGroup>
<!-- Same CA trust hook the MSI overlay ships; injected into every
extracted runtime so both install channels behave identically. -->
Expand Down
Loading