From ec59c3eafd2bc8b65a8734d1067a012fc8437009 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 14 Jul 2026 19:06:51 +0900 Subject: [PATCH 1/3] Set VERSIONINFO metadata on rb.exe SmartScreen and the file-properties dialog show these fields, and the code-signing provider requires them to identify the project. The release workflow overrides Version from the tag. --- src/rbmanager/rbmanager.csproj | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/rbmanager/rbmanager.csproj b/src/rbmanager/rbmanager.csproj index 1ccd5f5..d5176c6 100644 --- a/src/rbmanager/rbmanager.csproj +++ b/src/rbmanager/rbmanager.csproj @@ -15,6 +15,18 @@ true + + + 0.1.0 + rbmanager + Ruby version manager for Windows + Ruby Core Team + Copyright (c) 2026 Hiroshi SHIBATA + + From 7cc55b6598c0eaa40801be6115f3fb430fcb0401 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 14 Jul 2026 19:07:02 +0900 Subject: [PATCH 2/3] Publish NativeAOT rb.exe for win-x64 and win-arm64 in CI Catches AOT publish breakage on every push instead of at release time. The arm64 target cross-compiles on the x64 runner with the ARM64 MSVC tools present on the hosted image. CI artifacts are unsigned. --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 889029d..92d33c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 From 12ab6d59ec2b674b6d7281873c54c4620638533b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 14 Jul 2026 19:07:12 +0900 Subject: [PATCH 3/3] Add a tag-driven release workflow with code signing Pushing a v* tag builds rb.exe for win-x64 and win-arm64, signs both through SignPath Foundation, verifies the signatures, and attaches the binaries with .sha256 checksums to a GitHub release. Signing lives in the .github/actions/sign composite action so the planned migration to Azure Artifact Signing or an OV certificate only swaps that one seam. While the SignPath credentials are not configured, releases come out as unsigned drafts with a warning. docs/code-signing.md doubles as the signing policy and the SignPath Foundation application checklist. --- .github/actions/sign/action.yml | 78 +++++++++++++++++++++ .github/workflows/release.yml | 119 ++++++++++++++++++++++++++++++++ README.md | 20 +++++- docs/code-signing.md | 117 +++++++++++++++++++++++++++++++ 4 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 .github/actions/sign/action.yml create mode 100644 .github/workflows/release.yml create mode 100644 docs/code-signing.md diff --git a/.github/actions/sign/action.yml b/.github/actions/sign/action.yml new file mode 100644 index 0000000..54e105e --- /dev/null +++ b/.github/actions/sign/action.yml @@ -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..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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..352f8c9 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 + +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 diff --git a/README.md b/README.md index 044fbd3..66b806a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/code-signing.md b/docs/code-signing.md new file mode 100644 index 0000000..d913558 --- /dev/null +++ b/docs/code-signing.md @@ -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=`, 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.