From 4cd0cf4fe29667edd194490b68eb5f270b05e617 Mon Sep 17 00:00:00 2001 From: Omar Sy Date: Fri, 22 May 2026 22:47:13 +0200 Subject: [PATCH] feat(ci): conditional SignPath signing step for the Windows binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements workstream 2 of the greffer-windows-support epic (``docs/features/greffer-windows-support/hld.md``). The Windows binary has been publishing unsigned since workstream 1 landed (PR #39) — operators downloading via ``iwr | iex`` hit Defender SmartScreen on first run, with no path to fix it short of the right-click → Properties → Unblock dance. This adds three steps between the existing "Rename binary with target suffix" and "Upload binary artifact (Windows)" steps: 1. **Pre-signing upload** — uploads the unsigned ``.exe`` as a short-lived input artifact (``greffer-${target}-unsigned-input``, 1-day retention) so SignPath has an artifact-id to download. 2. **Submit signing request to SignPath** — ``signpath/github-action-submit-signing-request@v1.2``. Signs under the chosen policy, writes the signed binary back to ``cli/dist/``. The HLD picks SignPath because it's free for AGPL/OSS projects, API-driven (no HSM management), and issues an OV cert under the Greffon org name. 3. **Verify signed binary** — ``Get-AuthenticodeSignature`` check that fails the job loudly if the signed file is missing or has a non-Valid signature status. Without this, a silent SignPath failure would publish an unsigned binary indistinguishably. All three steps are gated on ``env.SIGNPATH_API_TOKEN != ''``. Reasoning: - During the gap between this landing and the SignPath account being live, every step skips cleanly. The pre-existing unsigned- binary upload still runs, so behaviour is unchanged from today. - The moment ``SIGNPATH_API_TOKEN`` and ``SIGNPATH_ORG_ID`` are added to repo secrets, the next ``cli-v*`` tag automatically produces a signed binary. No code change needed at go-live. GitHub Actions disallows ``secrets.X`` in ``if:`` expressions for security; the env-indirection ``env: SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}`` then ``if: env.SIGNPATH_API_TOKEN != ''`` is the workaround. Next steps (operator-side, not engineering): 1. Register the ``greffer-cli`` project on SignPath OSS community plan (free for AGPL). 2. Configure the ``release-signing`` policy and the ``greffer-exe`` artifact configuration as referenced in the step's ``with:`` block. 3. Add ``SIGNPATH_API_TOKEN`` + ``SIGNPATH_ORG_ID`` to repo secrets (Settings → Secrets and variables → Actions). 4. Cut the next ``cli-v*`` tag. The install.ps1 banner already mentions a SmartScreen "warm-up period" — that text is currently misleading because there is no signing at all yet. After SignPath is live and the first signed binary ships, the banner becomes accurate. Operator-facing copy fix is a separate small PR on landing-page. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/cli-release.yml | 83 +++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index e47757e..17cf526 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -158,6 +158,89 @@ jobs: if-no-files-found: error retention-days: 14 + # ---- Windows code signing (greffer-windows-support epic, workstream 2) ---- + # + # Signs the Windows binary via SignPath.io's free OSS community + # plan before final upload. The HLD picks SignPath because it's + # API-driven (no HSM management), issues an OV cert under our org + # name, and is free for AGPL projects. + # + # Conditional-on-secret pattern: the whole signing path is gated + # on the SignPath secrets existing. Reasoning: + # + # - During the period between this PR landing and the SignPath + # account being set up, every step here skips cleanly. The + # pre-existing unsigned-binary upload below still runs, so the + # workflow output is unchanged from today. + # + # - The moment SIGNPATH_API_TOKEN + SIGNPATH_ORG_ID are added to + # repo secrets, the next ``cli-v*`` tag automatically produces + # a signed binary — no code change needed at signing-go-live. + # + # GitHub Actions does not allow ``secrets.X`` references in + # ``if:`` expressions (security), so we go through an env-var + # indirection: copy the secret to env, then check ``env.X != ''``. + + - name: Upload pre-signing input artifact (Windows, if SignPath configured) + # SignPath downloads the artifact-id to sign; this step makes + # the unsigned binary visible to the SignPath action by upload- + # ing it as a separate, short-lived artifact. + if: runner.os == 'Windows' && env.SIGNPATH_API_TOKEN != '' + id: pre_sign_upload + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + uses: actions/upload-artifact@v4 + with: + name: greffer-${{ matrix.target }}-unsigned-input + path: cli/dist/greffer-${{ matrix.target }}.exe + if-no-files-found: error + retention-days: 1 + + - name: Submit signing request to SignPath + if: runner.os == 'Windows' && env.SIGNPATH_API_TOKEN != '' + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + # SignPath's published action; downloads the artifact-id from + # the previous step, signs it, and writes the signed file back + # to ``output-artifact-directory``. ``wait-for-completion`` + # blocks the job until the signing policy completes (default + # SignPath OSS policies auto-approve; review-required policies + # are also supported). + uses: signpath/github-action-submit-signing-request@v1.2 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ secrets.SIGNPATH_ORG_ID }} + project-slug: greffer-cli + signing-policy-slug: release-signing + artifact-configuration-slug: greffer-exe + github-artifact-id: ${{ steps.pre_sign_upload.outputs.artifact-id }} + wait-for-completion: true + output-artifact-directory: cli/dist/ + + - name: Verify signed binary + # Fail loudly if the signed file isn't present or doesn't have + # a valid Authenticode signature. Without this, a silent + # SignPath failure would produce an unsigned binary that gets + # uploaded below as if it were signed. + if: runner.os == 'Windows' && env.SIGNPATH_API_TOKEN != '' + shell: powershell + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + run: | + $exe = "cli/dist/greffer-${{ matrix.target }}.exe" + if (-not (Test-Path $exe)) { + Write-Error "Signed binary missing at $exe — SignPath step likely failed." + exit 1 + } + $sig = Get-AuthenticodeSignature $exe + if ($sig.Status -ne 'Valid') { + Write-Error "Authenticode signature status is '$($sig.Status)' on $exe (expected 'Valid')." + exit 1 + } + Write-Host "Verified: $($sig.SignerCertificate.Subject)" + + # ---- End signing block --------------------------------------------------- + - name: Upload binary artifact (Windows) if: runner.os == 'Windows' uses: actions/upload-artifact@v4