Code signing in GitHub Actions with Certum SimplySign
ssign runs on a Linux runner and signs Windows binaries there: no Windows runner, no SimplySign Desktop, no container. The account e-mail and the TOTP seed come from environment secrets.
A signing workflow
name: Sign
on:
workflow_dispatch: # manual only — each run performs a real cloud signature
jobs:
sign:
runs-on: ubuntu-latest
environment: signing # ← protected environment; see next section
steps:
- uses: actions/checkout@v4
- run: cargo install ssign
- run: ssign dist/*.exe
env:
CERTUM_EMAIL: ${{ secrets.CERTUM_EMAIL }}
CERTUM_OTP: ${{ secrets.CERTUM_OTP }}
- uses: actions/upload-artifact@v4
with: { name: signed, path: dist/ }
CERTUM_OTP holds your TOTP seed, the long-lived secret that lets anyone sign as you.
Store it as described below, not as a plain repository secret.
Require the owner's approval for every signing run
Signing uses your certificate and, if mishandled, can expose the seed. Gate it behind a GitHub Environment, so that the repository owner must approve every signing run before the job can even read the secrets:
- Settings → Environments → New environment, name it
signing. - Add a Deployment protection rule → Required reviewers, and add yourself (the owner). Any job that targets this environment now pauses for your approval.
- Store
CERTUM_EMAILandCERTUM_OTPas environment secrets ofsigning(not repository secrets). They are readable only inside an approved run of a job that declaresenvironment: signing. - In the workflow, the signing job declares
environment: signing, as above.
Result: a pull request, even a malicious one, cannot trigger a signature or read the seed; every
run waits for the owner's click. Also keep the signing job on workflow_dispatch or protected-branch
pushes, never on pull_request from forks.
In a release pipeline
Make signing a job in the same workflow as the build and the release, gated by
environment: signing, with the release job listing it in needs:. A tag then becomes
build → approve → signed release. Do not rely on a separate workflow triggered by
release: published: a release created by the built-in GITHUB_TOKEN does not
trigger other workflows.
A real example: ssign signs its own Windows release
ssign's release workflow does exactly this. On each version tag, a job on ubuntu-latest takes the Linux
ssign binary built earlier in the same run, waits for approval through the signing
environment, signs the Windows ssign.exe and ssign_pkcs11.dll with a single Certum login,
then checks both with osslsigncode verify. The release job waits for it. Abridged:
sign-windows:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
environment: signing
steps:
# …download the Linux ssign and the Windows artifacts built earlier in the run…
- name: Sign the Windows binary + module with ssign (via Certum)
env:
CERTUM_EMAIL: ${{ secrets.CERTUM_EMAIL }}
CERTUM_OTP: ${{ secrets.CERTUM_OTP }}
run: |
unzip -o dl/ssign-windows-x86_64.zip -d dl
unzip -o dl/ssign-pkcs11-windows-x86_64.zip -d dl
# One Certum login signs both the CLI and the module.
ssign --verbose \
-n "ssign" -u "https://github.com/${{ github.repository }}" \
dl/ssign.exe dl/ssign_pkcs11.dll
release:
needs: [build, sign-windows, sign-apple]
The full file: .github/workflows/ci.yml.
Several files, one login
Pass every file to one ssign call, as above: it logs in once for the whole batch. If a later step signs
other formats with the PKCS#11 module in the same job, the module reuses the cached session
instead of logging in again (see the session cache).