This runbook defines the repeatable process for signing official workflow bundles and verifying signatures in SpecFact CLI.
Modules docs handoff: this page remains in the core docs set as release-line overview content. Canonical bundle-specific deep guidance now lives in the canonical modules docs site, currently published at
https://modules.specfact.io/.
Key Placement
Repository/public key path used by CLI verification:
resources/keys/module-signing-public.pem(repository source path)
Runtime key resolution order:
- Explicit key argument (internal verifier calls)
SPECFACT_MODULE_PUBLIC_KEY_PEM- Bundled key file at
resources/keys/module-signing-public.pem(source) orspecfact_cli/resources/keys/module-signing-public.pem(installed package)
Never store private signing keys in the repository.
Generate Keys
Ed25519 (recommended):
openssl genpkey -algorithm ED25519 -out module-signing-private.pem
openssl pkey -in module-signing-private.pem -pubout -out module-signing-public.pem
RSA 4096 (supported):
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out module-signing-private.pem
openssl pkey -in module-signing-private.pem -pubout -out module-signing-public.pem
Sign Official Bundles
Preferred (strict, with private key):
- Key file:
--key-file <path>or setSPECFACT_MODULE_PRIVATE_SIGN_KEY_FILE(or legacySPECFACT_MODULE_SIGNING_PRIVATE_KEY_FILE). - Inline PEM: Set
SPECFACT_MODULE_PRIVATE_SIGN_KEY(or legacySPECFACT_MODULE_SIGNING_PRIVATE_KEY_PEM) to the PEM string; no file needed. Useful in CI where the key is in a secret.
KEY_FILE="${SPECFACT_MODULE_PRIVATE_SIGN_KEY_FILE:-.specfact/sign-keys/module-signing-private.pem}"
python scripts/sign-modules.py --key-file "$KEY_FILE" src/specfact_cli/modules/*/module-package.yaml
python scripts/sign-modules.py --key-file "$KEY_FILE" packages/*/module-package.yaml
Encrypted private key options:
# Prompt interactively for passphrase (TTY)
python scripts/sign-modules.py --key-file "$KEY_FILE" packages/specfact-backlog/module-package.yaml
# Explicit passphrase flag (avoid shell history when possible)
python scripts/sign-modules.py --key-file "$KEY_FILE" --passphrase '***' packages/specfact-backlog/module-package.yaml
# Passphrase over stdin (CI-safe pattern)
printf '%s' "$SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" | \
python scripts/sign-modules.py --key-file "$KEY_FILE" --passphrase-stdin packages/specfact-backlog/module-package.yaml
Versioning guard:
- The signer enforces module version increments for changed module contents.
- If module files changed and version is unchanged, signing fails until version is bumped.
- Override exists for exceptional local workflows:
--allow-same-version(not recommended). - Module versions are independent from CLI package version; bump only modules whose payload changed.
Changed-modules automation (recommended for release prep):
# Bump changed modules by patch and sign only those modules
hatch run python scripts/sign-modules.py \
--key-file "$KEY_FILE" \
--changed-only \
--base-ref origin/dev \
--bump-version patch
# Verify after signing (strict bundle; add --version-check-base when comparing to a branch)
hatch run verify-modules-signature --version-check-base origin/dev
Wrapper for single manifest:
bash scripts/sign-module.sh --key-file "$KEY_FILE" packages/specfact-backlog/module-package.yaml
# stdin passphrase:
printf '%s' "$SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" | \
bash scripts/sign-module.sh --key-file "$KEY_FILE" --passphrase-stdin packages/specfact-backlog/module-package.yaml
Local test-only unsigned mode:
python scripts/sign-modules.py --allow-unsigned packages/specfact-backlog/module-package.yaml
Verify Signatures Locally
Strict verification (checksum + signature required):
python scripts/verify-modules-signature.py --require-signature
With explicit public key file:
python scripts/verify-modules-signature.py --require-signature --public-key-file resources/keys/module-signing-public.pem
PR / feature-branch parity with pre-commit omit (version bump vs base; defer checksum to CI):
hatch run verify-modules-signature-pr --version-check-base origin/dev
Post-merge / push-style checksum + version (no --require-signature; matches VERIFY_MODULES_PUSH_ORCHESTRATOR):
hatch run python scripts/verify-modules-signature.py --enforce-version-bump --payload-from-filesystem
Do not pass --allow-unsigned to verify-modules-signature.py — it is not a supported argument there.
Use python scripts/sign-modules.py --allow-unsigned … only when you intentionally want checksum-only
signing for local tests.
Pre-commit (bundled modules in this repository)
If you use pre-commit or scripts/setup-git-hooks.sh, commits that stage changes under modules/ or
src/specfact_cli/modules/ run scripts/pre-commit-verify-modules.sh, which sources
scripts/module-verify-policy.sh. On main it runs VERIFY_MODULES_STRICT (checksum +
--require-signature); elsewhere it runs VERIFY_MODULES_PR (version bump only via
--skip-checksum-verification) so you are not forced to re-sign locally before CI.
CI Enforcement
Canonical flag bundles live in scripts/module-verify-policy.sh and are sourced by:
pr-orchestrator.ymljobverify-module-signatures: pull requests useVERIFY_MODULES_PR(same as pre-commit omit). Pushes todev/mainuseVERIFY_MODULES_PUSH_ORCHESTRATOR(payload checksum + version bump; no--require-signaturein this job).sign-modules.ymljobverify: push todevormainrunsVERIFY_MODULES_STRICTafter the auto-sign step. Pull requests andworkflow_dispatchuseVERIFY_MODULES_PR.
Strict signatures on protected branches are enforced by sign-modules.yml (and local main
pre-commit), not by adding --require-signature to the PR orchestrator verify step.
Rotation Procedure
- Generate new keypair in secure environment.
- Replace
resources/keys/module-signing-public.pemwith new public key. - Re-sign all official bundle manifests with the new private key.
- Run verifier locally:
hatch run verify-modules-signature(equivalent strict flags are inscripts/module-verify-policy.shasVERIFY_MODULES_STRICT). - Commit public key + re-signed manifests in one change.
- Merge to
dev, thenmainafter CI passes.
Revocation Procedure
If a private key is compromised:
- Treat all signatures from that key as untrusted.
- Generate new keypair immediately.
- Replace public key file in repo.
- Re-sign all official bundles with new private key.
- Merge emergency fix branch and invalidate prior release artifacts operationally.
Current limitation:
- Runtime key-revocation list support is not yet implemented.
- Revocation is currently handled by rotating the trusted public key and re-signing all bundled manifests.