- 1. Executive Summary
- 2. Option 1: GitHub Packages (npm/Cargo)
- 3. Option 2: GitHub Container Registry (ghcr.io)
- 4. Option 3: GitHub Releases
- 5. Option 4: GitHub Actions Artifacts
- 6. Comparison Matrix
- 7. Recommended Architecture
- 8. Proof of Concept Implementation Plan
- 9. Configuration Examples
- 10. Migration Path
- 11. Security Considerations
- 12. Conclusion
This document evaluates GitHub’s built-in registry systems as alternatives to a self-hosted registry for storing and distributing hypatia rulesets. The analysis covers four options: GitHub Packages, GitHub Container Registry (ghcr.io), GitHub Releases, and GitHub Actions Artifacts.
|
Important
|
Recommended approach: GitHub Container Registry (ghcr.io) with OCI Artifacts This provides the best balance of:
|
GitHub Packages provides package hosting for multiple ecosystems. For hypatia rulesets, we could package them as:
-
npm packages: JSON/YAML rulesets as npm modules
-
Cargo crates: Rust-based ruleset loaders (not the rulesets themselves)
Rulesets would be published as scoped npm packages:
{
"name": "@hyperpolymath/ruleset-security-workflow-pins",
"version": "1.0.0",
"description": "GitHub Actions SHA pinning rules",
"main": "ruleset.yaml",
"files": ["ruleset.yaml", "rules/*.yaml"],
"keywords": ["hypatia", "ruleset", "security"],
"repository": "https://github.com/hyperpolymath/hypatia",
"license": "MPL-2.0"
}| Pros | Cons |
|---|---|
Familiar npm/Cargo tooling |
Requires ecosystem-specific packaging |
Semantic versioning built-in |
npm: Node.js dependency for install |
Dependency resolution handled |
Cargo: Only viable for Rust rulesets |
Package locking (package-lock.json) |
Overhead of package.json per ruleset |
GitHub integration (GITHUB_TOKEN) |
Not designed for generic artifacts |
Free for public packages |
Mixes package concepts with rulesets |
# npm authentication
echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" >> ~/.npmrc
echo "@hyperpolymath:registry=https://npm.pkg.github.com" >> ~/.npmrc
# Cargo authentication (in .cargo/config.toml)
[registries.github]
index = "sparse+https://github.com/hyperpolymath/hypatia-registry/index/"
token = "Bearer ${GITHUB_TOKEN}"# npm publish
npm publish --registry=https://npm.pkg.github.com
# Via API
curl -X PUT \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @package.tgz \
"https://npm.pkg.github.com/@hyperpolymath/ruleset-security-workflow-pins"# npm install
npm install @hyperpolymath/ruleset-security-workflow-pins
# Direct download via API
curl -H "Authorization: Bearer ${GITHUB_TOKEN}" \
"https://npm.pkg.github.com/@hyperpolymath/ruleset-security-workflow-pins/-/ruleset-security-workflow-pins-1.0.0.tgz" \
-o ruleset.tgz// cli/src/commands/deposit.rs additions
/// Submit ruleset as npm package to GitHub Packages
async fn deposit_to_github_packages(
ruleset: &Ruleset,
content: &str,
config: &Config,
) -> Result<String> {
let token = std::env::var("GITHUB_TOKEN")
.context("GITHUB_TOKEN required for GitHub Packages")?;
// Create package.json wrapper
let package_json = serde_json::json!({
"name": format!("@hyperpolymath/ruleset-{}", ruleset.metadata.name),
"version": ruleset.metadata.version,
"description": ruleset.metadata.description,
"main": "ruleset.yaml",
"files": ["ruleset.yaml"],
"keywords": ruleset.metadata.tags,
"license": ruleset.metadata.license,
});
// Create tarball and publish
// ... implementation details
Ok(format!("@hyperpolymath/ruleset-{}", ruleset.metadata.name))
}GitHub Container Registry (ghcr.io) supports OCI artifacts, allowing storage of any content type with container-like tagging and versioning. Rulesets can be pushed as OCI artifacts using ORAS (OCI Registry As Storage).
ghcr.io/hyperpolymath/hypatia-rulesets/security/workflow-pins:1.0.0
│ │ │ │
└─ org name │ │ └─ version tag
│ └─ ruleset name
└─ category{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.hypatia.ruleset.config.v1+json",
"size": 233,
"digest": "sha256:abc..."
},
"layers": [
{
"mediaType": "application/vnd.hypatia.ruleset.content.v1+yaml",
"size": 4521,
"digest": "sha256:def...",
"annotations": {
"org.opencontainers.image.title": "workflow-pins.yaml",
"dev.hypatia.category": "security",
"dev.hypatia.version": "1.0.0"
}
}
],
"annotations": {
"org.opencontainers.image.source": "https://github.com/hyperpolymath/hypatia",
"org.opencontainers.image.description": "GitHub Actions SHA pinning rules",
"org.opencontainers.image.licenses": "MPL-2.0"
}
}| Pros | Cons |
|---|---|
Content-addressable (SHA digests) |
Requires ORAS or similar tooling |
OCI standard = portable |
Slightly more complex than releases |
Fine-grained visibility (public/private) |
Learning curve for OCI concepts |
Immutable tags with digest pinning |
ghcr.io specific (not pure git) |
GitHub Actions integration |
Rate limits for unauthenticated pulls |
Unlimited storage for public |
No built-in search (need catalog) |
Supports signatures (Cosign/Notary) |
- |
# Docker/ORAS login
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
echo $GITHUB_TOKEN | oras login ghcr.io -u USERNAME --password-stdin
# Personal Access Token requirements:
# - read:packages (for withdraw)
# - write:packages (for deposit)
# - delete:packages (optional, for removal)# Install ORAS
brew install oras # or: go install oras.land/oras/cmd/oras@latest
# Push ruleset as OCI artifact
oras push ghcr.io/hyperpolymath/hypatia-rulesets/security/workflow-pins:1.0.0 \
--artifact-type application/vnd.hypatia.ruleset.v1 \
--annotation "org.opencontainers.image.source=https://github.com/hyperpolymath/hypatia" \
--annotation "dev.hypatia.category=security" \
workflow-pins.yaml:application/vnd.hypatia.ruleset.content.v1+yaml
# Tag as latest
oras tag ghcr.io/hyperpolymath/hypatia-rulesets/security/workflow-pins:1.0.0 latest# Pull ruleset
oras pull ghcr.io/hyperpolymath/hypatia-rulesets/security/workflow-pins:1.0.0 \
--output ./rulesets/
# Pull by digest (immutable)
oras pull ghcr.io/hyperpolymath/hypatia-rulesets/security/workflow-pins@sha256:abc123...
# List tags
oras repo tags ghcr.io/hyperpolymath/hypatia-rulesets/security/workflow-pins# List packages
curl -H "Authorization: Bearer ${GITHUB_TOKEN}" \
"https://api.github.com/orgs/hyperpolymath/packages?package_type=container"
# Get package versions
curl -H "Authorization: Bearer ${GITHUB_TOKEN}" \
"https://api.github.com/orgs/hyperpolymath/packages/container/hypatia-rulesets%2Fsecurity%2Fworkflow-pins/versions"
# Delete a version (admin only)
curl -X DELETE -H "Authorization: Bearer ${GITHUB_TOKEN}" \
"https://api.github.com/orgs/hyperpolymath/packages/container/hypatia-rulesets%2Fsecurity%2Fworkflow-pins/versions/12345"// cli/src/registry/ghcr.rs
use oci_distribution::{Client, Reference, secrets::RegistryAuth};
/// OCI-based registry client for ghcr.io
pub struct GhcrRegistry {
client: Client,
namespace: String,
}
impl GhcrRegistry {
pub fn new(token: &str) -> Result<Self> {
let auth = RegistryAuth::Basic(
std::env::var("GITHUB_USERNAME")?,
token.to_string(),
);
let client = Client::new(ClientConfig {
protocol: ClientProtocol::Https,
..Default::default()
});
Ok(Self {
client,
namespace: "ghcr.io/hyperpolymath/hypatia-rulesets".to_string(),
})
}
/// Deposit a ruleset to ghcr.io
pub async fn deposit(&self, ruleset: &Ruleset, content: &[u8]) -> Result<String> {
let reference = Reference::try_from(format!(
"{}/{}/{}:{}",
self.namespace,
ruleset.metadata.category,
ruleset.metadata.name,
ruleset.metadata.version
))?;
// Create OCI manifest
let config = OciConfig {
media_type: "application/vnd.hypatia.ruleset.config.v1+json",
config: serde_json::to_vec(&ruleset.metadata)?,
};
let layer = OciLayer {
media_type: "application/vnd.hypatia.ruleset.content.v1+yaml",
data: content.to_vec(),
annotations: HashMap::from([
("dev.hypatia.category".into(), ruleset.metadata.category.to_string()),
]),
};
let manifest = OciManifest::build()
.config(config)
.layer(layer)
.annotations(HashMap::from([
("org.opencontainers.image.source".into(),
"https://github.com/hyperpolymath/hypatia".into()),
]))
.build()?;
let digest = self.client.push(&reference, &manifest).await?;
Ok(format!("{}@{}", reference, digest))
}
/// Withdraw a ruleset from ghcr.io
pub async fn withdraw(&self, name: &str, version: Option<&str>) -> Result<(Ruleset, Vec<u8>)> {
let (category, ruleset_name, ver) = parse_ruleset_id(name)?;
let version = version.unwrap_or(&ver);
let reference = Reference::try_from(format!(
"{}/{}/{}:{}",
self.namespace, category, ruleset_name, version
))?;
let (manifest, layers) = self.client.pull(&reference).await?;
// Parse config as RulesetMetadata
let config: RulesetMetadata = serde_json::from_slice(&manifest.config.data)?;
// Parse layer as ruleset content
let content = layers.first()
.context("No ruleset content in artifact")?
.data
.clone();
let ruleset = parse_ruleset_content(&content, &config)?;
Ok((ruleset, content))
}
}GitHub Releases attach binary assets to git tags, providing a simple way to distribute versioned artifacts alongside source code.
| Pros | Cons |
|---|---|
Very simple conceptually |
Limited versioning (per-release, not per-ruleset) |
Direct download URLs (no auth for public) |
No individual ruleset versioning |
Native GitHub integration |
Requires managing release lifecycle |
GitHub Actions auto-release |
Max 2GB per asset |
Signed releases (GPG/sigstore) |
No search/discovery mechanism |
No special tooling needed |
Bundling reduces granularity |
# For private repos only
curl -H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/octet-stream" \
"https://api.github.com/repos/hyperpolymath/hypatia-rulesets/releases/assets/12345"
# Public repos: Direct download, no auth needed
curl -LO "https://github.com/hyperpolymath/hypatia-rulesets/releases/download/v1.0.0/workflow-pins.yaml"# Create release via gh CLI
gh release create v1.0.0 \
--repo hyperpolymath/hypatia-rulesets \
--title "Rulesets v1.0.0" \
--notes "Security and policy rulesets" \
./rulesets/*.yaml
# Via API
curl -X POST \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/hyperpolymath/hypatia-rulesets/releases" \
-d '{
"tag_name": "v1.0.0",
"name": "Rulesets v1.0.0",
"body": "Security and policy rulesets",
"draft": false,
"prerelease": false
}'
# Upload asset
curl -X POST \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Content-Type: application/x-yaml" \
--data-binary @workflow-pins.yaml \
"https://uploads.github.com/repos/hyperpolymath/hypatia-rulesets/releases/12345/assets?name=workflow-pins.yaml"# Direct download (public)
curl -LO "https://github.com/hyperpolymath/hypatia-rulesets/releases/download/v1.0.0/workflow-pins.yaml"
# Via gh CLI
gh release download v1.0.0 \
--repo hyperpolymath/hypatia-rulesets \
--pattern "*.yaml" \
--dir ./rulesets/
# List releases
gh release list --repo hyperpolymath/hypatia-rulesets// cli/src/registry/github_releases.rs
/// GitHub Releases-based registry
pub struct GitHubReleasesRegistry {
client: reqwest::Client,
owner: String,
repo: String,
token: Option<String>,
}
impl GitHubReleasesRegistry {
/// Deposit ruleset as release asset
pub async fn deposit(&self, ruleset: &Ruleset, content: &[u8]) -> Result<String> {
let token = self.token.as_ref()
.context("GITHUB_TOKEN required for deposits")?;
// Check if release exists for this version, create if not
let release = self.get_or_create_release(&ruleset.metadata.version).await?;
// Upload asset
let filename = format!(
"{}-{}-{}.yaml",
ruleset.metadata.category,
ruleset.metadata.name,
ruleset.metadata.version
);
let upload_url = release.upload_url
.replace("{?name,label}", &format!("?name={}", filename));
let response = self.client
.post(&upload_url)
.header("Authorization", format!("Bearer {}", token))
.header("Content-Type", "application/x-yaml")
.body(content.to_vec())
.send()
.await?;
let asset: GitHubAsset = response.json().await?;
Ok(asset.browser_download_url)
}
/// Withdraw ruleset from releases
pub async fn withdraw(
&self,
name: &str,
version: Option<&str>,
) -> Result<(Ruleset, Vec<u8>)> {
let (category, ruleset_name, ver) = parse_ruleset_id(name)?;
let version = version.unwrap_or(&ver);
// Find release
let release = self.get_release(version).await?;
// Find matching asset
let asset_name = format!("{}-{}-{}.yaml", category, ruleset_name, version);
let asset = release.assets.iter()
.find(|a| a.name == asset_name)
.context("Ruleset not found in release")?;
// Download asset
let content = self.download_asset(&asset.browser_download_url).await?;
let ruleset = parse_ruleset_yaml(&content)?;
Ok((ruleset, content))
}
}GitHub Actions artifacts store workflow outputs temporarily (90 days default). Useful for build-time ruleset generation and sharing between jobs/workflows.
Best for:
-
CI/CD-generated rulesets (learned patterns)
-
Temporary sharing between workflow jobs
-
Build caching and incremental compilation
-
Testing ruleset changes in PRs
| Pros | Cons |
|---|---|
Automatic from Actions |
Expires after 90 days (configurable 1-400) |
Good for CI/CD pipelines |
Private by default |
Inter-job data sharing |
No public download URLs |
Free for public repos |
Workflow-scoped access |
Large size support (500MB) |
Not a permanent registry |
# .github/workflows/generate-rulesets.yml
name: Generate Rulesets
on:
schedule:
- cron: '0 0 * * 0' # Weekly
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Generate rulesets from learned patterns
run: |
./scripts/generate-rulesets.sh
- name: Upload rulesets artifact
uses: actions/upload-artifact@v4
with:
name: generated-rulesets-${{ github.run_id }}
path: ./generated-rulesets/
retention-days: 90# List artifacts
curl -H "Authorization: Bearer ${GITHUB_TOKEN}" \
"https://api.github.com/repos/hyperpolymath/hypatia/actions/artifacts"
# Download artifact (returns zip)
curl -H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-L -o artifact.zip \
"https://api.github.com/repos/hyperpolymath/hypatia/actions/artifacts/12345/zip"┌─────────────────────────────────────────────────────────────────┐
│ Artifact Decision Tree │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Is the ruleset generated during CI/CD? │
│ ├── YES → Is it needed beyond 90 days? │
│ │ ├── YES → Use ghcr.io or Releases after generation │
│ │ └── NO → Actions Artifacts are perfect │
│ └── NO → Use ghcr.io, Releases, or Packages │
│ │
│ Is the ruleset shared between workflow jobs? │
│ └── YES → Actions Artifacts (with download-artifact@v4) │
│ │
└─────────────────────────────────────────────────────────────────┘| Feature | Packages (npm) | ghcr.io (OCI) | Releases | Artifacts |
|---|---|---|---|---|
Permanence |
Permanent |
Permanent |
Permanent |
90 days |
Versioning |
Semantic (npm) |
Tags + Digests |
Git tags |
Run ID |
Immutability |
Version locks |
SHA digests |
Not guaranteed |
No |
Search |
npm search |
Catalog API |
GitHub search |
Workflow scope |
Auth Required |
Always |
Public: No |
Public: No |
Always |
CLI Tooling |
npm |
oras/docker |
gh/curl |
gh/curl |
Free (public) |
500 MB |
Unlimited |
Unlimited |
Included |
Content Type |
Packages |
Any artifact |
Binary assets |
Zip archives |
Signatures |
npm audit |
Cosign/Notary |
GPG |
No |
hypatia Fit |
Medium |
Best |
Good |
CI only |
┌─────────────────────────────────────────────────────────────────────┐
│ hypatia Registry Architecture │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ PRIMARY: ghcr.io (OCI) │ │
│ │ │ │
│ │ ghcr.io/hyperpolymath/hypatia-rulesets/ │ │
│ │ ├── security/workflow-pins:1.0.0 │ │
│ │ ├── security/codeql-matrix:1.0.0 │ │
│ │ ├── policy/spdx-headers:1.0.0 │ │
│ │ └── ... │ │
│ │ │ │
│ │ Features: │ │
│ │ - SHA digest pinning for reproducibility │ │
│ │ - OCI annotations for metadata │ │
│ │ - Cosign signatures for supply chain security │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ SECONDARY: GitHub Releases │ │
│ │ │ │
│ │ Bundled downloads for offline/air-gapped environments: │ │
│ │ - hypatia-rulesets-v1.0.0-all.tar.gz │ │
│ │ - hypatia-rulesets-v1.0.0-security.tar.gz │ │
│ │ - SHA256SUMS + SHA256SUMS.sig │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ CI/CD: GitHub Actions Artifacts │ │
│ │ │ │
│ │ - Neural-generated rulesets (temporary) │ │
│ │ - PR testing artifacts │ │
│ │ - Build cache │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘cli/src/registry/
├── mod.rs # Registry trait + dispatcher
├── ghcr.rs # NEW: ghcr.io OCI client
├── oci.rs # NEW: OCI manifest handling
└── local.rs # Existing local cache// SPDX-License-Identifier: CC-BY-SA-4.0
//! Registry abstraction for multiple backends
use async_trait::async_trait;
/// Registry backend types
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegistryBackend {
/// Local filesystem cache
Local,
/// Self-hosted registry (default)
SelfHosted,
/// GitHub Container Registry (ghcr.io)
Ghcr,
/// GitHub Releases
GitHubReleases,
}
/// Trait for registry implementations
#[async_trait]
pub trait Registry: Send + Sync {
/// Deposit a ruleset into the registry
async fn deposit(&self, ruleset: &Ruleset, content: &[u8]) -> Result<DepositResult>;
/// Withdraw a ruleset from the registry
async fn withdraw(&self, name: &str, version: Option<&str>) -> Result<WithdrawResult>;
/// Search for rulesets
async fn search(&self, query: &SearchQuery) -> Result<Vec<RulesetSummary>>;
/// List all available rulesets
async fn list(&self, filter: Option<&RulesetFilter>) -> Result<Vec<RulesetSummary>>;
/// Get registry backend type
fn backend(&self) -> RegistryBackend;
}
/// Create registry client from configuration
pub fn create_registry(config: &Config) -> Result<Box<dyn Registry>> {
let backend = config.registry_backend.as_deref()
.map(|s| match s {
"ghcr" | "ghcr.io" => RegistryBackend::Ghcr,
"releases" | "github-releases" => RegistryBackend::GitHubReleases,
"local" => RegistryBackend::Local,
_ => RegistryBackend::SelfHosted,
})
.unwrap_or(RegistryBackend::SelfHosted);
match backend {
RegistryBackend::Ghcr => {
let token = std::env::var("GITHUB_TOKEN")
.context("GITHUB_TOKEN required for ghcr.io")?;
Ok(Box::new(GhcrRegistry::new(&token)?))
}
RegistryBackend::GitHubReleases => {
let token = std::env::var("GITHUB_TOKEN").ok();
Ok(Box::new(GitHubReleasesRegistry::new(
"hyperpolymath",
"hypatia-rulesets",
token.as_deref(),
)?))
}
RegistryBackend::Local => {
Ok(Box::new(LocalRegistry::new(config.cache_dir())))
}
RegistryBackend::SelfHosted => {
let url = config.registry_url.clone()
.unwrap_or_else(|| "https://registry.hypatia.dev".into());
Ok(Box::new(SelfHostedRegistry::new(&url)?))
}
}
}// SPDX-License-Identifier: CC-BY-SA-4.0
//! GitHub Container Registry (ghcr.io) integration using OCI artifacts
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::{Registry, RegistryBackend, DepositResult, WithdrawResult};
use crate::commands::deposit::{Ruleset, RulesetMetadata};
const GHCR_HOST: &str = "ghcr.io";
const NAMESPACE: &str = "hyperpolymath/hypatia-rulesets";
/// Custom media types for hypatia rulesets
mod media_types {
pub const CONFIG: &str = "application/vnd.hypatia.ruleset.config.v1+json";
pub const CONTENT_YAML: &str = "application/vnd.hypatia.ruleset.content.v1+yaml";
pub const CONTENT_JSON: &str = "application/vnd.hypatia.ruleset.content.v1+json";
}
/// ghcr.io registry client
pub struct GhcrRegistry {
client: Client,
token: String,
namespace: String,
}
impl GhcrRegistry {
pub fn new(token: &str) -> Result<Self> {
let client = Client::builder()
.user_agent("hypatia-cli/0.1.0")
.build()?;
Ok(Self {
client,
token: token.to_string(),
namespace: NAMESPACE.to_string(),
})
}
/// Get token for ghcr.io authentication
async fn get_auth_token(&self, scope: &str) -> Result<String> {
// Exchange GitHub token for ghcr.io token
let url = format!(
"https://{}/token?service=ghcr.io&scope={}",
GHCR_HOST, scope
);
let resp = self.client
.get(&url)
.basic_auth("", Some(&self.token))
.send()
.await?;
let token_resp: TokenResponse = resp.json().await?;
Ok(token_resp.token)
}
/// Build OCI manifest for a ruleset
fn build_manifest(
&self,
config_digest: &str,
config_size: usize,
layer_digest: &str,
layer_size: usize,
metadata: &RulesetMetadata,
) -> OciManifest {
OciManifest {
schema_version: 2,
media_type: "application/vnd.oci.image.manifest.v1+json".into(),
config: OciDescriptor {
media_type: media_types::CONFIG.into(),
size: config_size,
digest: config_digest.into(),
},
layers: vec![OciDescriptor {
media_type: media_types::CONTENT_YAML.into(),
size: layer_size,
digest: layer_digest.into(),
}],
annotations: Some(HashMap::from([
("org.opencontainers.image.source".into(),
"https://github.com/hyperpolymath/hypatia".into()),
("org.opencontainers.image.description".into(),
metadata.description.clone()),
("org.opencontainers.image.licenses".into(),
metadata.license.clone()),
("dev.hypatia.category".into(),
metadata.category.to_string()),
("dev.hypatia.version".into(),
metadata.version.clone()),
])),
}
}
/// Calculate SHA256 digest
fn sha256_digest(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
format!("sha256:{}", hex::encode(hasher.finalize()))
}
}
#[async_trait]
impl Registry for GhcrRegistry {
async fn deposit(&self, ruleset: &Ruleset, content: &[u8]) -> Result<DepositResult> {
let reference = format!(
"{}/{}/{}:{}",
self.namespace,
ruleset.metadata.category,
ruleset.metadata.name,
ruleset.metadata.version
);
let scope = format!("repository:{}/{}:push,pull", self.namespace, ruleset.metadata.category);
let token = self.get_auth_token(&scope).await?;
// Upload config blob
let config_json = serde_json::to_vec(&ruleset.metadata)?;
let config_digest = Self::sha256_digest(&config_json);
self.upload_blob(&token, &reference, &config_json, &config_digest).await?;
// Upload content blob
let content_digest = Self::sha256_digest(content);
self.upload_blob(&token, &reference, content, &content_digest).await?;
// Create and upload manifest
let manifest = self.build_manifest(
&config_digest,
config_json.len(),
&content_digest,
content.len(),
&ruleset.metadata,
);
let manifest_digest = self.upload_manifest(&token, &reference, &manifest).await?;
Ok(DepositResult {
success: true,
ruleset_id: Some(format!("ghcr.io/{}@{}", reference, manifest_digest)),
content_hash: content_digest,
deposited_at: chrono::Utc::now(),
registry_url: format!("https://ghcr.io/{}", reference),
validation: ValidationResult { valid: true, rules_count: ruleset.rules.len(), errors: vec![], warnings: vec![] },
warnings: vec![],
})
}
async fn withdraw(&self, name: &str, version: Option<&str>) -> Result<WithdrawResult> {
let (category, ruleset_name, ver) = parse_ruleset_id(name)?;
let version = version.unwrap_or(&ver);
let reference = format!("{}/{}/{}:{}", self.namespace, category, ruleset_name, version);
let scope = format!("repository:{}/{}:pull", self.namespace, category);
let token = self.get_auth_token(&scope).await?;
// Fetch manifest
let manifest = self.fetch_manifest(&token, &reference).await?;
// Download config
let config_data = self.download_blob(&token, &reference, &manifest.config.digest).await?;
let metadata: RulesetMetadata = serde_json::from_slice(&config_data)?;
// Download content
let layer = manifest.layers.first().context("No content layer")?;
let content = self.download_blob(&token, &reference, &layer.digest).await?;
let ruleset = parse_ruleset_content(&content, &metadata)?;
Ok(WithdrawResult {
success: true,
ruleset_id: name.to_string(),
version: version.to_string(),
saved_to: None,
registry_url: format!("https://ghcr.io/{}", reference),
withdrawn_at: chrono::Utc::now(),
metadata: ruleset.metadata.clone(),
content: Some(ruleset),
})
}
async fn search(&self, query: &SearchQuery) -> Result<Vec<RulesetSummary>> {
// ghcr.io doesn't have native search - use GitHub API to list packages
// and filter locally
let packages = self.list_packages().await?;
Ok(packages.into_iter()
.filter(|p| query.matches(p))
.collect())
}
async fn list(&self, filter: Option<&RulesetFilter>) -> Result<Vec<RulesetSummary>> {
self.list_packages().await
}
fn backend(&self) -> RegistryBackend {
RegistryBackend::Ghcr
}
}// Add to Config struct
pub struct Config {
// ... existing fields ...
/// Registry backend type (ghcr, releases, local, or self-hosted URL)
pub registry_backend: Option<String>,
/// GitHub username (for ghcr.io authentication)
pub github_username: Option<String>,
}// Update execute function
pub async fn execute(args: DepositArgs, config: &Config, format: OutputFormat) -> Result<()> {
// ... validation code unchanged ...
// Create registry client based on config
let registry = registry::create_registry(config)?;
// Log which backend we're using
info!("Using registry backend: {:?}", registry.backend());
// Submit to registry
let result = registry.deposit(&ruleset, content.as_bytes()).await?;
// ... output handling unchanged ...
}// Update execute function
pub async fn execute(args: WithdrawArgs, config: &Config, format: OutputFormat) -> Result<()> {
// Create registry client
let registry = registry::create_registry(config)?;
info!(
"Withdrawing '{}' from {:?} registry",
args.ruleset_name,
registry.backend()
);
// Fetch ruleset
let result = registry.withdraw(&args.ruleset_name, args.version.as_deref()).await?;
// ... rest unchanged ...
}// SPDX-License-Identifier: CC-BY-SA-4.0
//! GitHub Releases integration for bundled ruleset distribution
use async_trait::async_trait;
use reqwest::Client;
use super::{Registry, RegistryBackend};
pub struct GitHubReleasesRegistry {
client: Client,
owner: String,
repo: String,
token: Option<String>,
}
impl GitHubReleasesRegistry {
pub fn new(owner: &str, repo: &str, token: Option<&str>) -> Result<Self> {
Ok(Self {
client: Client::builder()
.user_agent("hypatia-cli/0.1.0")
.build()?,
owner: owner.to_string(),
repo: repo.to_string(),
token: token.map(String::from),
})
}
async fn get_release(&self, tag: &str) -> Result<GitHubRelease> {
let url = format!(
"https://api.github.com/repos/{}/{}/releases/tags/{}",
self.owner, self.repo, tag
);
let mut req = self.client.get(&url)
.header("Accept", "application/vnd.github+json");
if let Some(ref token) = self.token {
req = req.header("Authorization", format!("Bearer {}", token));
}
let resp = req.send().await?;
resp.json().await.context("Failed to parse release")
}
}
#[async_trait]
impl Registry for GitHubReleasesRegistry {
async fn deposit(&self, ruleset: &Ruleset, content: &[u8]) -> Result<DepositResult> {
let token = self.token.as_ref()
.context("GITHUB_TOKEN required for GitHub Releases deposits")?;
// Get or create release for this version
let release = match self.get_release(&format!("v{}", ruleset.metadata.version)).await {
Ok(r) => r,
Err(_) => self.create_release(&ruleset.metadata.version).await?,
};
// Upload asset
let filename = format!(
"{}-{}-{}.yaml",
ruleset.metadata.category,
ruleset.metadata.name,
ruleset.metadata.version
);
let upload_url = release.upload_url
.replace("{?name,label}", &format!("?name={}", urlencoding::encode(&filename)));
let resp = self.client
.post(&upload_url)
.header("Authorization", format!("Bearer {}", token))
.header("Content-Type", "application/x-yaml")
.body(content.to_vec())
.send()
.await?;
let asset: GitHubAsset = resp.json().await?;
Ok(DepositResult {
success: true,
ruleset_id: Some(filename),
content_hash: compute_hash(content),
deposited_at: chrono::Utc::now(),
registry_url: asset.browser_download_url,
validation: ValidationResult { valid: true, rules_count: ruleset.rules.len(), errors: vec![], warnings: vec![] },
warnings: vec![],
})
}
async fn withdraw(&self, name: &str, version: Option<&str>) -> Result<WithdrawResult> {
let (category, ruleset_name, ver) = parse_ruleset_id(name)?;
let version = version.unwrap_or(&ver);
let release = self.get_release(&format!("v{}", version)).await?;
let asset_name = format!("{}-{}-{}.yaml", category, ruleset_name, version);
let asset = release.assets.iter()
.find(|a| a.name == asset_name)
.context("Ruleset not found in release")?;
// Download
let mut req = self.client.get(&asset.browser_download_url);
if let Some(ref token) = self.token {
req = req.header("Authorization", format!("Bearer {}", token));
}
let resp = req.send().await?;
let content = resp.bytes().await?.to_vec();
let ruleset = parse_ruleset_yaml(&content)?;
Ok(WithdrawResult {
success: true,
ruleset_id: name.to_string(),
version: version.to_string(),
saved_to: None,
registry_url: asset.browser_download_url.clone(),
withdrawn_at: chrono::Utc::now(),
metadata: ruleset.metadata.clone(),
content: Some(ruleset),
})
}
fn backend(&self) -> RegistryBackend {
RegistryBackend::GitHubReleases
}
}// tests/integration/registry_tests.rs
#[tokio::test]
#[ignore] // Requires GITHUB_TOKEN
async fn test_ghcr_deposit_withdraw() {
let token = std::env::var("GITHUB_TOKEN").unwrap();
let registry = GhcrRegistry::new(&token).unwrap();
let ruleset = create_test_ruleset();
let content = serde_yaml::to_vec(&ruleset).unwrap();
// Deposit
let deposit_result = registry.deposit(&ruleset, &content).await.unwrap();
assert!(deposit_result.success);
// Withdraw
let withdraw_result = registry.withdraw(
&format!("{}/{}", ruleset.metadata.category, ruleset.metadata.name),
Some(&ruleset.metadata.version),
).await.unwrap();
assert!(withdraw_result.success);
assert_eq!(withdraw_result.content.unwrap().rules.len(), ruleset.rules.len());
}# .github/workflows/release-rulesets.yml
# SPDX-License-Identifier: CC-BY-SA-4.0
name: Release Rulesets
on:
push:
tags:
- 'rulesets-v*'
permissions:
contents: write
packages: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- name: Install ORAS
run: |
curl -LO https://github.com/oras-project/oras/releases/download/v1.1.0/oras_1.1.0_linux_amd64.tar.gz
tar xzf oras_1.1.0_linux_amd64.tar.gz
sudo mv oras /usr/local/bin/
- name: Login to ghcr.io
run: echo ${{ secrets.GITHUB_TOKEN }} | oras login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push rulesets to ghcr.io
run: |
for file in registry/rulesets/*.yaml; do
name=$(basename "$file" .yaml)
category=$(yq '.metadata.category' "$file")
version=$(yq '.metadata.version' "$file")
oras push "ghcr.io/hyperpolymath/hypatia-rulesets/${category}/${name}:${version}" \
--artifact-type application/vnd.hypatia.ruleset.v1 \
"$file:application/vnd.hypatia.ruleset.content.v1+yaml"
oras tag "ghcr.io/hyperpolymath/hypatia-rulesets/${category}/${name}:${version}" latest
done
- name: Create GitHub Release
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
with:
files: registry/rulesets/*.yaml
generate_release_notes: true# ~/.config/hypatia/config.toml
# Use GitHub Container Registry
registry_backend = "ghcr"
# Optional: Override namespace
# registry_namespace = "hyperpolymath/hypatia-rulesets"# Set token
export GITHUB_TOKEN="ghp_xxxxx"
# Deposit
hyper deposit ./my-ruleset.yaml
# Withdraw
hyper withdraw security/workflow-pins:1.0.0# ~/.config/hypatia/config.toml
registry_backend = "releases"
github_owner = "hyperpolymath"
github_repo = "hypatia-rulesets"# Withdraw (public, no token needed)
hyper withdraw security/workflow-pins --version 1.0.0
# Deposit (requires token)
export GITHUB_TOKEN="ghp_xxxxx"
hyper deposit ./my-ruleset.yaml# Registry backend selection
export HYPER_REGISTRY_BACKEND="ghcr" # or "releases", "local", or URL
# Authentication
export GITHUB_TOKEN="ghp_xxxxx"
export GITHUB_USERNAME="hyperpolymath" # Optional for ghcr
# Custom namespace (ghcr only)
export HYPER_REGISTRY_NAMESPACE="myorg/my-rulesets"#!/bin/bash
# migrate-to-ghcr.sh
# SPDX-License-Identifier: CC-BY-SA-4.0
set -euo pipefail
OLD_REGISTRY="https://registry.hypatia.dev"
NEW_NAMESPACE="ghcr.io/hyperpolymath/hypatia-rulesets"
# Export all rulesets from old registry
hyper search --registry "$OLD_REGISTRY" --output json | jq -r '.[].id' | while read id; do
echo "Migrating: $id"
# Withdraw from old
hyper withdraw "$id" --registry "$OLD_REGISTRY" --output ./tmp/
# Deposit to new
HYPER_REGISTRY_BACKEND=ghcr hyper deposit ./tmp/*.yaml
rm -rf ./tmp/
done
echo "Migration complete!"# Sign artifacts during deposit
cosign sign ghcr.io/hyperpolymath/hypatia-rulesets/security/workflow-pins:1.0.0
# Verify during withdraw
cosign verify ghcr.io/hyperpolymath/hypatia-rulesets/security/workflow-pins:1.0.0 \
--certificate-identity https://github.com/hyperpolymath/hypatia/.github/workflows/release-rulesets.yml@refs/tags/rulesets-v1.0.0 \
--certificate-oidc-issuer https://token.actions.githubusercontent.comThe recommended implementation uses ghcr.io as the primary registry for several key reasons:
-
Unlimited free storage for public artifacts
-
Content-addressable with SHA digests for reproducibility
-
Industry-standard OCI format enables interoperability
-
Native GitHub Actions integration for CI/CD automation
-
Signing support via Cosign for supply chain security
GitHub Releases serves as a secondary distribution mechanism for bundled downloads and air-gapped environments.
The proof-of-concept implementation requires approximately 4 weeks of development effort, with the core registry abstraction providing flexibility to add additional backends in the future.