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/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 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. 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 + +