Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ jobs:
run: python3 tools/check_shell_commands.py --strict
- name: Dependency security audit
run: pip-audit -r tools/requirements.txt
- name: Dependency security audit (publish lock files)
# The hash-locked sets publish-registry.yml installs, including the
# signing job's cryptography stack.
run: pip-audit --require-hashes -r tools/requirements.lock -r tools/requirements-sign.lock

# -------------------------------------------------------------------------
# Sandbox containment check for changed skill scripts. Deliberately scoped
Expand Down
118 changes: 90 additions & 28 deletions .github/workflows/publish-registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,65 +7,127 @@ on:
paths:
- 'categories/**'
- 'tools/**'
- 'keys/**'
- 'manifest-schema.toml'
- '.github/workflows/publish-registry.yml'
workflow_dispatch:

# Two quick merges must not race on `gh release upload --clobber` and leave a
# registry.json / registry-signature.json pair from different commits on the
# rolling release. Queue runs (never cancel one mid-upload) so the newest main
# always publishes last.
concurrency:
group: publish-registry
cancel-in-progress: false

permissions:
contents: read

jobs:
build-and-publish:
# Builds registry.json without any secret and without write access. The
# validator and generator need pyyaml/rich, which never run next to the
# signing key.
build:
name: Build registry
runs-on: ubuntu-latest
permissions:
contents: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.11'
cache: 'pip'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f tools/requirements.txt ]; then pip install -r tools/requirements.txt; fi
- name: Install hash-locked dependencies
run: python -m pip install --require-hashes --no-deps -r tools/requirements.lock

- name: Validate skills
run: |
- name: Validate skills (zero-warning gate)
run: >-
python tools/validate_skill.py --all
--warning-budget tools/validation_warning_budget.json

- name: Generate registry artifact
run: |
python tools/update_registry.py

- name: Sign registry manifest
# Ed25519 (asymmetric) is preferred when the SKILLS_ED25519_PRIVATE_KEY
# secret is configured (generate once with: python tools/sign_manifest.py
# keygen). Pin the matching public key for verifiers. Until that secret
# exists, fall back to the legacy shared-secret HMAC scheme so CI keeps
# working — migration follow-up: make Ed25519 mandatory.
- name: Generate registry
run: python tools/update_registry.py

- name: Hand the unsigned registry to the signing job
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: registry-unsigned
path: registry.json
if-no-files-found: error
retention-days: 1

# The only job that sees SKILLS_ED25519_PRIVATE_KEY or holds contents:write.
# It installs nothing but the hash-locked cryptography stack.
sign-and-publish:
name: Sign, verify and publish registry
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.11'

- name: Install hash-locked signing dependencies
run: python -m pip install --require-hashes --no-deps -r tools/requirements-sign.lock

- name: Download unsigned registry
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: registry-unsigned
path: .

- name: Sign registry (Ed25519)
# Ed25519 is the only scheme. There is deliberately no fallback: an
# unset secret fails the run instead of publishing an unsigned or
# forgeable registry-signature.json (the old shared-secret path fell
# back to a literal key anyone could read in this file).
env:
SIGNING_KEY: ${{ secrets.SKILLS_SIGNING_KEY || 'dev-fallback-key' }}
SKILLS_ED25519_PRIVATE_KEY: ${{ secrets.SKILLS_ED25519_PRIVATE_KEY }}
run: |
if [ -n "$SKILLS_ED25519_PRIVATE_KEY" ]; then
python tools/sign_manifest.py sign registry.json --ed25519 > registry-signature.json
else
python tools/sign_manifest.py sign registry.json --key "$SIGNING_KEY" > registry-signature.json
if [ -z "${SKILLS_ED25519_PRIVATE_KEY}" ]; then
echo "::error::SKILLS_ED25519_PRIVATE_KEY is not set; refusing to publish an unsigned registry."
exit 1
fi
python tools/sign_manifest.py sign registry.json --ed25519 > registry-signature.json
cat registry-signature.json

- name: Verify signature with the committed public key
# Proves the secret matches keys/registry-ed25519.pub (the key Rho
# pins) before anything is uploaded; a mismatched or rotated secret
# fails here instead of breaking every client.
run: >-
python tools/sign_manifest.py verify registry.json
--signature-file registry-signature.json
--key keys/registry-ed25519.pub

- name: Upload registry artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: skill-registry
path: |
registry.json
registry-signature.json
if-no-files-found: error
retention-days: 90

- name: Publish registry to the rolling release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
# One moving release holds the current registry. The Actions
# artifact above is retained separately for 90-day forensics.
Expand All @@ -75,7 +137,7 @@ jobs:
if ! gh release view registry-latest >/dev/null 2>&1; then
gh release create registry-latest \
--title "Skill registry (rolling)" \
--notes "Generated registry.json for the current main. Updated automatically; do not delete." \
--notes "Generated registry.json for the current main, signed with the Ed25519 key in keys/registry-ed25519.pub. Updated automatically; do not delete." \
--latest=false
fi
gh release upload registry-latest \
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,29 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
- CI/CD workflows for PR checks
- 14,015+ community skill packages across 27 categories

### Security
- `publish-registry.yml` no longer falls back to the literal `dev-fallback-key`
when `SKILLS_ED25519_PRIVATE_KEY` / `SKILLS_SIGNING_KEY` are unconfigured. It
previously published a `registry-signature.json` that looked authoritative but
was forgeable by anyone who could read the workflow. `tools/sign_manifest.py`
already exited non-zero without a key, so removing the fallback makes an
unconfigured secret fail the release loudly instead of shipping a meaningless
signature.
- The registry is now signed with **Ed25519 only**. The HMAC-SHA256 scheme and
`SKILLS_SIGNING_KEY` were removed from `tools/sign_manifest.py`, and the
pinned public key is committed at `keys/registry-ed25519.pub`. The publish
job fails when `SKILLS_ED25519_PRIVATE_KEY` is unset and verifies its own
signature with the committed key before uploading. Registries published
before this change carry a forgeable `hmac-sha256` signature; clients must
not trust them. **Breaking:** `sign`/`verify` reject HMAC secrets.
- `publish-registry.yml` pins every action to a commit SHA, installs only
hash-locked dependencies (`tools/requirements.lock`,
`tools/requirements-sign.lock`), keeps the signing key in a separate job
that installs nothing but the `cryptography` stack, and serialises runs
with a `publish-registry` concurrency group.
- `verify --signature-file` checks a published `registry-signature.json`
end to end (algorithm, target, SHA-256, signature). See `docs/REGISTRY.md`.

## [0.1.0] - 2026-05-26

### Changed
Expand Down
90 changes: 86 additions & 4 deletions docs/REGISTRY.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,99 @@
# Skill Registry

`registry.json` is a **generated artifact** — it is NOT committed to git.
`registry.json` is a **generated artifact**. It is not committed to git; the
source of truth is the `SKILL.md` files under `categories/`.

## Generate locally

```bash
python tools/update_registry.py
```

## In CI
## Published registry

The registry is generated fresh in CI before publishing to the CDN. It is not stored in the repository.
On every push to `main` that touches `categories/`, `tools/`, `keys/`,
`manifest-schema.toml` or the workflow itself,
[`publish-registry.yml`](../.github/workflows/publish-registry.yml) validates the
corpus, regenerates `registry.json`, signs it, verifies the signature, and
uploads both files to the rolling GitHub release `registry-latest`:

- `https://github.com/GrayCodeAI/graycode-skills/releases/download/registry-latest/registry.json`
- `https://github.com/GrayCodeAI/graycode-skills/releases/download/registry-latest/registry-signature.json`

This is the index Rho reads for `rho skills search`, `info` and `trending`. There
is no CDN; the GitHub release is the distribution point. Each run also keeps
both files as a 90-day Actions artifact (`skill-registry`) for forensics.

## Signature

The registry is signed with **Ed25519 only**. The public key is committed at
[`keys/registry-ed25519.pub`](../keys/registry-ed25519.pub) and pinned by Rho:

```
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAr9I2NG1Sih9Mu04/eOA8FmJhczSLBYiXeLAl1rqusQU=
-----END PUBLIC KEY-----
```

The private key exists only as the `SKILLS_ED25519_PRIVATE_KEY` GitHub Actions
secret. If that secret is unset the publish job fails; it never falls back to
another scheme or an unsigned upload.

`registry-signature.json` is the JSON printed by
`python tools/sign_manifest.py sign registry.json --ed25519`:

```json
{
"target": "registry.json",
"sha256": "<lowercase hex SHA-256 of the exact registry.json bytes>",
"algorithm": "ed25519",
"signature": "<hex Ed25519 signature>"
}
```

The signed message is the 64 ASCII bytes of the lowercase hex digest, not the
raw 32-byte digest.

### Verifying

A client must:

1. download both files;
2. require `algorithm == "ed25519"`;
3. recompute the SHA-256 of the downloaded `registry.json` bytes and require it
to equal `sha256`;
4. verify `signature` over the hex digest with the pinned public key;
5. refuse to use the index if any step fails (no unsigned fallback).

With this repository checked out:

```bash
python tools/sign_manifest.py verify registry.json \
--signature-file registry-signature.json
```

`verify` uses `keys/registry-ed25519.pub` unless `--key` or
`SKILLS_ED25519_PUBLIC_KEY` is given. With OpenSSL 3 only:

```bash
jq -r .signature registry-signature.json | xxd -r -p > registry.sig
printf '%s' "$(shasum -a 256 registry.json | cut -c1-64)" > registry.sha256
openssl pkeyutl -verify -pubin -inkey keys/registry-ed25519.pub \
-rawin -in registry.sha256 -sigfile registry.sig
```

The publish workflow runs the same `verify` against the committed key before
it uploads anything.

### Rotating the key

1. `python tools/sign_manifest.py keygen --private-out <secure path> --public-out keys/registry-ed25519.pub`
2. Store the private PEM as the `SKILLS_ED25519_PRIVATE_KEY` secret; never commit it.
3. Update the expected key in `tests/test_sign_manifest.py` and ship the new
pinned key in a Rho release before, or together with, the first registry
signed by the new key. Old clients reject registries signed by a key they
do not pin.

## Why not in git?

At 4+ MB, committing `registry.json` creates excessive diff noise and slows clones. The source of truth is the individual `SKILL.md` files under `categories/`.
At about 6 MB, committing `registry.json` would add diff noise and slow clones.
3 changes: 3 additions & 0 deletions keys/registry-ed25519.pub
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAr9I2NG1Sih9Mu04/eOA8FmJhczSLBYiXeLAl1rqusQU=
-----END PUBLIC KEY-----
Loading
Loading