Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
# smoke-test all four bundles, then upload them (plus .sha256 companions) to
# the matching stackql/stackql GitHub release.
#
# Can also be re-run from the Actions tab (workflow_dispatch) to re-publish
# the pinned release from current main - e.g. after enabling signing secrets -
# without moving the tag. The confirm_release input must match release.yaml.
#
# Requires one repo secret:
# STACKQL_RELEASE_TOKEN - fine-grained PAT (or GitHub App token) with
# contents:write on stackql/stackql. The default GITHUB_TOKEN is scoped to
Expand All @@ -12,6 +16,12 @@ name: publish
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
confirm_release:
description: Re-publish guard - type the release exactly as pinned in release.yaml (e.g. v0.10.500)
required: true
type: string

permissions:
contents: read
Expand All @@ -27,11 +37,17 @@ jobs:

- name: Compare tag against pinned release
id: cfg
env:
CONFIRM_RELEASE: ${{ inputs.confirm_release }}
run: |
cfg="$(sed -n 's/^stackql_release:[[:space:]]*//p' release.yaml | tr -d ' \r')"
tag="$GITHUB_REF_NAME"
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
tag="$CONFIRM_RELEASE"; ctx="confirm_release input"
else
tag="$GITHUB_REF_NAME"; ctx="tag"
fi
if [ "$tag" != "$cfg" ]; then
echo "::error::tag '$tag' does not match stackql_release '$cfg' in release.yaml"
echo "::error::$ctx '$tag' does not match stackql_release '$cfg' in release.yaml"
exit 1
fi
echo "publishing for $tag"
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ stackql-mcpb-packaging/
scripts/package.sh # build bundles from bin/ -> dist/
scripts/clean.sh # wipe dist/
scripts/render-server-json.sh # pin SHAs into registry/server.json
scripts/sign.sh # envelope-sign dist/*.mcpb + regen .sha256
scripts/append-signature.py # frame an externally-produced CMS signature
scripts/smoke-test.py # deterministic MCP smoke test (stdlib only)
scripts/gemini-smoke.py # optional Gemini Flash agent smoke test
docs/install.md # end-user install guide
Expand Down Expand Up @@ -87,6 +89,8 @@ The sequence:
3. **Merge to main** - nothing is published yet.
4. **Push the matching tag** - `git tag vX.Y.Z && git push origin vX.Y.Z`. [publish.yml](.github/workflows/publish.yml) fails fast if the tag does not exactly match `release.yaml`, rebuilds and re-tests everything, then uploads all `.mcpb` + `.sha256` files to the `stackql/stackql` `vX.Y.Z` release via `make publish` (idempotent `--clobber`).

To re-publish the pinned release without moving the tag (e.g. after enabling signing secrets), run the publish workflow from the Actions tab (`workflow_dispatch`); the `confirm_release` input must be typed exactly as pinned in `release.yaml` (e.g. `v0.10.500`). It runs from current main and clobbers the existing release assets.

One-time setup: add a repo secret `STACKQL_RELEASE_TOKEN` - a fine-grained PAT (or GitHub App token) with `contents:write` on `stackql/stackql`. The default `GITHUB_TOKEN` cannot upload assets to another repo. Optionally add `GEMINI_API_KEY` to enable the agent smoke test on the linux-x64 job; without it that step soft-skips.

Optional envelope signing: if the repo secrets `MCPB_SIGNING_CERT` and `MCPB_SIGNING_KEY` (PEM contents, plus optional `MCPB_SIGNING_INTERMEDIATES`) are set, the publish job runs `make sign` to `mcpb sign` every bundle and regenerate its `.sha256` before upload. Without the secrets the step prints a notice and skips, and unsigned bundles ship as before. Note `mcpb verify` in the current CLI is broken upstream (node-forge cannot verify PKCS#7, so every signed bundle reports as unsigned); `make sign` treats it as advisory and asserts the appended signature block instead.
Expand Down Expand Up @@ -265,6 +269,28 @@ Dormant, intentionally:

For end users, this means: the binary that actually runs is fully signed by the platform's trust authority; the bundle wrapping it is hash-pinned and registry-verified; the editorial vetting is layered on top via the Anthropic directory. Self-signed envelopes (`make signed`) are for local testing only and not suitable for release.

### Envelope signing with the hardware token

`mcpb sign` requires a PEM key on disk, which the token cannot export. The workaround is to produce the detached CMS signature externally and frame it with [scripts/append-signature.py](scripts/append-signature.py), which emits the same byte layout as `mcpb sign` (`MCPB_SIG_V1` + 4-byte LE length + DER PKCS#7 + `MCPB_SIG_END`) and regenerates the `.sha256`:

```bash
# 1. Sign the unsigned bundle bytes with the token via the PKCS#11 engine
# (prompts for the token PIN; cert.pem/chain.pem are the public materials
# exported from the token).
openssl cms -sign -binary -in dist/stackql-mcp-linux-x64.mcpb \
-signer cert.pem -certfile chain.pem \
-keyform engine -engine pkcs11 -inkey "pkcs11:type=private" \
-outform DER -out sig.der

# 2. Frame and append it, regenerating the .sha256.
python scripts/append-signature.py dist/stackql-mcp-linux-x64.mcpb sig.der

# 3. Re-upload (idempotent).
make publish VERSION=X.Y.Z
```

This is interactive (PIN prompt), so it is a local flow, not a CI step. Requires an OpenSSL build with the PKCS#11 engine (libp11) pointed at the token vendor's PKCS#11 module. `--strip-only` removes an existing signature block if you need the unsigned bytes back.

## Makefile reference

```
Expand Down
94 changes: 94 additions & 0 deletions scripts/append-signature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Append an externally-produced PKCS#7/CMS signature to a .mcpb bundle in the
MCPB signature framing, and regenerate the bundle's .sha256.

This exists for signing keys that cannot be exported to PEM (hardware tokens,
HSMs): produce a detached DER SignedData over the unsigned bundle bytes with
any external tool (e.g. OpenSSL with a PKCS#11 engine), then frame and append
it here. The result is byte-compatible with 'mcpb sign' output:

[zip bytes][MCPB_SIG_V1][4-byte LE sig length][DER PKCS#7][MCPB_SIG_END]

Typical flow with a hardware token:

openssl cms -sign -binary -in dist/stackql-mcp-linux-x64.mcpb \
-signer cert.pem -certfile chain.pem \
-keyform engine -engine pkcs11 -inkey "pkcs11:type=private" \
-outform DER -out sig.der
python scripts/append-signature.py dist/stackql-mcp-linux-x64.mcpb sig.der

If the bundle already carries a signature block it is replaced (the external
signature must have been produced over the *unsigned* bytes; pass the bundle
through this script only after signing the stripped content - or use
--strip-only first to get the unsigned bytes to sign).

Usage:
python scripts/append-signature.py <bundle.mcpb> <signature.der>
python scripts/append-signature.py --strip-only <bundle.mcpb>
"""
from __future__ import annotations

import hashlib
import struct
import sys
from pathlib import Path

HEADER = b"MCPB_SIG_V1"
FOOTER = b"MCPB_SIG_END"


def strip_signature(content: bytes) -> bytes:
"""Return the bundle bytes without any existing signature block."""
footer_idx = content.rfind(FOOTER)
if footer_idx == -1:
return content
header_idx = content.rfind(HEADER, 0, footer_idx)
if header_idx == -1:
return content
return content[:header_idx]


def write_sha256(bundle: Path) -> None:
digest = hashlib.sha256(bundle.read_bytes()).hexdigest()
sha_path = bundle.with_name(bundle.name + ".sha256")
sha_path.write_text(f"{digest} {bundle.name}\n")
print(f"{digest} {bundle.name}")


def main(argv: list[str]) -> int:
if len(argv) == 3 and argv[1] == "--strip-only":
bundle = Path(argv[2])
stripped = strip_signature(bundle.read_bytes())
bundle.write_bytes(stripped)
write_sha256(bundle)
print(f"stripped signature block (if any) from {bundle.name}")
return 0

if len(argv) != 3:
print(__doc__, file=sys.stderr)
return 2

bundle, sig = Path(argv[1]), Path(argv[2])
if not bundle.exists():
print(f"error: bundle not found: {bundle}", file=sys.stderr)
return 1
if not sig.exists():
print(f"error: signature not found: {sig}", file=sys.stderr)
return 1

sig_der = sig.read_bytes()
if not sig_der.startswith(b"\x30"):
print("error: signature does not look like DER (no ASN.1 SEQUENCE)", file=sys.stderr)
return 1

content = strip_signature(bundle.read_bytes())
framed = content + HEADER + struct.pack("<I", len(sig_der)) + sig_der + FOOTER
bundle.write_bytes(framed)
print(f"appended {len(sig_der)}-byte signature to {bundle.name}")
write_sha256(bundle)
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv))
Loading