CI/CD Signing¶
This page is for teams who want their build pipeline (CI/CD, the automated build and release system) to sign an agent's manifest every time its configuration changes, and to fail the build if the result does not match what was approved. You get two Python scripts, a GitHub Actions workflow that runs them, and the steps for changing the signing key later.
The workflow signs the manifest when its source file changes on main, then rejects it if it does not match the verifier's approved runtime inputs.
What you'll learn¶
- Generate a signing key pair once and store the private key as a GitHub secret
- Sign a manifest in a GitHub Actions workflow
- Require a
VALIDverification result as a CI step - Rotate the signing key using an explicit trust-distribution procedure
Prerequisites¶
Generate the keypair once¶
The signing key is the secret that makes a manifest trustworthy, so create it once, carefully, and keep it out of the repository. Generate the issuer key through your approved key-management system. For a local development key, manifest keygen -d keys/ writes key files rather than printing the private key. Keep private files out of source control and logs.
Provision MANIFEST_SIGNING_KEY as base64url-encoded raw Ed25519 private bytes using your secret-management process. Configure MANIFEST_PUBLIC_KEY (base64url public bytes), MANIFEST_KEY_ID and MANIFEST_AUTHORIZED_ISSUER (the issuer that key may sign for, spec §5.3.3) independently on the verifier. The public key can be shared openly; what matters is that nobody can swap it. The example workflow below references the three key secrets as Actions secrets and the issuer as an Actions variable.
The verifier also needs its own record of what was approved. Provide approved-context.json containing the recipient's approved runtime observations using the fields of VerificationContext, such as prompt and policy hashes, enforcement mode, and model version. Maintain it under the verifier's change controls; do not generate expected values by copying them from the incoming manifest. Missing required observations can produce INCOMPLETE even when the signature is valid.
Write the signing script¶
Create scripts/sign_manifest.py in your repo. The workflow will call this script.
# scripts/sign_manifest.py
import json
import os
import sys
from base64 import urlsafe_b64decode
from pathlib import Path
from agent_manifest import Ed25519Signer
from agent_manifest._signing import ed25519_from_private_bytes
def main():
manifest_path = Path(sys.argv[1])
output_path = Path(sys.argv[2]) if len(sys.argv) > 2 else manifest_path
private_b64url = os.environ["MANIFEST_SIGNING_KEY"]
# Decode base64url to raw bytes, then reconstruct the keypair
pad = 4 - len(private_b64url) % 4
raw = urlsafe_b64decode(private_b64url + ("=" * pad if pad != 4 else ""))
kp = ed25519_from_private_bytes(raw)
with open(manifest_path) as f:
manifest_dict = json.load(f)
# Strip any existing signature before re-signing
manifest_dict.pop("signature", None)
signer = Ed25519Signer(kp)
manifest_dict["signature"] = signer.sign(manifest_dict)
with open(output_path, "w") as f:
json.dump(manifest_dict, f, indent=2)
print(f"Signed: {manifest_dict['manifest_id']}")
print(f"Key ID: {manifest_dict['signature']['key_id']}")
if __name__ == "__main__":
main()
Write the verification script¶
Create scripts/verify_manifest.py. This script exits with code 1 if verification fails, and GitHub Actions treats a non-zero exit code as a build failure.
# scripts/verify_manifest.py
import json
import os
import sys
from pathlib import Path
from agent_manifest import verify_manifest, VerificationContext, RevocationStore, OverallResult
def main():
manifest_path = Path(sys.argv[1])
public_b64url = os.environ["MANIFEST_PUBLIC_KEY"]
key_id = os.environ["MANIFEST_KEY_ID"]
authorized_issuer = os.environ["MANIFEST_AUTHORIZED_ISSUER"]
with open(manifest_path) as f:
manifest_dict = json.load(f)
context_path = Path(os.environ["MANIFEST_CONTEXT_FILE"])
approved_inputs = json.loads(context_path.read_text())
# Trust is configured here, never selected by the received manifest.
approved_inputs["trusted_keys"] = {key_id: public_b64url}
# Spec 5.3.3: the key must be authorized for the manifest's issuer.
approved_inputs["trusted_key_issuers"] = {key_id: [authorized_issuer]}
ctx = VerificationContext.model_validate(approved_inputs)
result = verify_manifest(manifest_dict, ctx, RevocationStore())
if result.result != OverallResult.VALID:
print(f"FAIL: {result.result}", file=sys.stderr)
for detail in result.mismatch_details:
print(f" {detail.field}: expected {detail.expected_hash}, got {detail.actual_hash}", file=sys.stderr)
sys.exit(1)
print(f"OK: {result.manifest_id} verified ({result.result})")
if __name__ == "__main__":
main()
GitHub Actions workflow¶
# .github/workflows/sign-manifest.yml
name: Sign and verify agent manifest
on:
push:
branches: [main]
paths:
- "agent-manifest.json"
workflow_dispatch:
jobs:
sign:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install agent-manifest
run: pip install agent-manifest
- name: Sign the manifest
env:
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: python scripts/sign_manifest.py agent-manifest.json signed-agent-manifest.json
- name: Verify the manifest against approved inputs
env:
MANIFEST_PUBLIC_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }}
MANIFEST_KEY_ID: ${{ secrets.MANIFEST_KEY_ID }}
MANIFEST_AUTHORIZED_ISSUER: ${{ vars.MANIFEST_AUTHORIZED_ISSUER }}
MANIFEST_CONTEXT_FILE: approved-context.json
run: python scripts/verify_manifest.py signed-agent-manifest.json
- name: Commit the signed manifest
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add signed-agent-manifest.json
git diff --staged --quiet || git commit -m "chore: update signed agent manifest [skip ci]"
git push
The verify step accepts only VALID with the configured runtime inputs. This is a build check, so a few things a live deployment needs are left out. The empty RevocationStore in this build-only example performs no live revocation refresh; configure authenticated revocation state for a deployment acceptance gate. This example targets the v0.1 JSON format, not v0.2 COSE bytes. Protect the workflow, signing secrets, and approved-context file from untrusted changes before using it for release signing.
Key rotation¶
Rotation means replacing the signing key. Every service that checks your manifests must learn the new public key before you start signing with it. Distribute and verify new public-key trust before switching a planned issuer key. Issue replacement manifests with new IDs when old IDs will be revoked, and refresh relying parties' revocation stores explicitly. A compromised key is not a safe overlap or rollback option. Follow the key rotation runbook; availability depends on the rollout and has no fixed guarantee.
Next steps¶
For key compromise procedures, see Revocation and key rotation. For a local signing and verification walkthrough, see Your first manifest.