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
46 changes: 46 additions & 0 deletions .github/workflows/ci-opencode-plugin.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: CI - OpenCode Plugin

on:
pull_request:
paths:
- 'cupcake-plugins/opencode/**'
- '.github/workflows/ci-opencode-plugin.yml'
push:
branches: [main]
paths:
- 'cupcake-plugins/opencode/**'
- '.github/workflows/ci-opencode-plugin.yml'

jobs:
build:
name: Build & Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
working-directory: cupcake-plugins/opencode
run: bun install

- name: Type check
working-directory: cupcake-plugins/opencode
run: bun run typecheck

- name: Build
working-directory: cupcake-plugins/opencode
run: bun run build

- name: Verify output exists
working-directory: cupcake-plugins/opencode
run: |
if [ ! -f dist/cupcake.js ]; then
echo "Error: dist/cupcake.js not found"
exit 1
fi
echo "Build output size: $(wc -c < dist/cupcake.js) bytes"
82 changes: 82 additions & 0 deletions .github/workflows/release-opencode-plugin.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Release OpenCode Plugin

on:
push:
branches: [main]
paths:
- 'cupcake-plugins/opencode/**'
workflow_dispatch:

jobs:
build-and-release:
name: Build and Release Plugin
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
working-directory: cupcake-plugins/opencode
run: bun install

- name: Type check
working-directory: cupcake-plugins/opencode
run: bun run typecheck

- name: Build plugin
working-directory: cupcake-plugins/opencode
run: bun run build

- name: Generate checksum
working-directory: cupcake-plugins/opencode/dist
run: |
sha256sum cupcake.js > cupcake.js.sha256
echo "Generated checksum:"
cat cupcake.js.sha256

- name: Get commit info
id: commit
run: |
echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT

- name: Update plugin release
uses: softprops/action-gh-release@v2
with:
tag_name: opencode-plugin-latest
name: OpenCode Plugin (Latest)
body: |
Latest build of the Cupcake OpenCode plugin.

This release is automatically updated when changes are merged to main.

**Build:** ${{ steps.commit.outputs.sha_short }} (${{ steps.commit.outputs.date }})

## Files
- `opencode-plugin.js` - The plugin file
- `opencode-plugin.js.sha256` - SHA256 checksum

## Installation

The plugin is automatically downloaded when you run:
```bash
cupcake init --harness opencode
```

Or manually download:
```bash
curl -fsSL https://github.com/eqtylab/cupcake/releases/download/opencode-plugin-latest/opencode-plugin.js \
-o .opencode/plugin/cupcake.js
```
files: |
cupcake-plugins/opencode/dist/cupcake.js#opencode-plugin.js
cupcake-plugins/opencode/dist/cupcake.js.sha256#opencode-plugin.js.sha256
make_latest: false
prerelease: false
50 changes: 2 additions & 48 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,54 +163,8 @@ jobs:
draft: true
prerelease: ${{ contains(steps.get_version.outputs.version, '-') }}

# Build OpenCode plugin
build-opencode-plugin:
name: Build OpenCode Plugin
needs: create-release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Install dependencies
working-directory: cupcake-plugins/opencode
run: npm install

- name: Build plugin
working-directory: cupcake-plugins/opencode
run: npm run build

- name: Generate checksum
working-directory: cupcake-plugins/opencode/dist
run: |
sha256sum cupcake.js > cupcake.js.sha256
echo "Generated checksum:"
cat cupcake.js.sha256

- name: Upload plugin to release
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: cupcake-plugins/opencode/dist/cupcake.js
asset_name: opencode-plugin.js
asset_content_type: application/javascript

- name: Upload plugin checksum
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: cupcake-plugins/opencode/dist/cupcake.js.sha256
asset_name: opencode-plugin.js.sha256
asset_content_type: text/plain
# NOTE: OpenCode plugin is released separately via release-opencode-plugin.yml
# It uses a dedicated release tag (opencode-plugin-latest) that is updated on merges to main

# Upload install scripts to release
upload-install-scripts:
Expand Down
165 changes: 119 additions & 46 deletions cupcake-cli/src/catalog_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,12 @@ async fn execute_lint(path: &std::path::Path) -> Result<()> {
errors.push(format!("Manifest validation failed: {e}"));
}

// Check for system/evaluate.rego at rulebook root (shared entrypoint)
let system_eval = path.join("system").join("evaluate.rego");
if !system_eval.exists() {
errors.push("Missing system/evaluate.rego at rulebook root".to_string());
}

// Check policies exist for declared harnesses
let policies_dir = path.join("policies");
if !policies_dir.exists() {
Expand All @@ -692,27 +698,17 @@ async fn execute_lint(path: &std::path::Path) -> Result<()> {
continue;
}

// Check for system/evaluate.rego
let system_eval = harness_dir.join("system").join("evaluate.rego");
if !system_eval.exists() {
errors.push(format!(
"Missing system/evaluate.rego for harness: {harness}"
));
}

// Check that at least some .rego files exist
let rego_files = count_rego_files(&harness_dir);
// Check that harness directory has at least one .rego file directly
let rego_files = count_rego_files_direct(&harness_dir);
if rego_files == 0 {
errors.push(format!("No .rego files found for harness: {harness}"));
errors.push(format!("No .rego policy files in policies/{}/", harness));
}
}
}

// Validate Rego namespaces
if policies_dir.exists() {
if let Err(e) = validate_rego_namespaces(path, &manifest.metadata.name) {
errors.push(format!("Namespace validation failed: {e}"));
}
// Validate Rego namespaces (policies, helpers, and system)
if let Err(e) = validate_rego_namespaces(path, &manifest.metadata.name) {
errors.push(format!("Namespace validation failed: {}", e));
}

// Check for README
Expand Down Expand Up @@ -745,25 +741,89 @@ async fn execute_lint(path: &std::path::Path) -> Result<()> {
Ok(())
}

fn count_rego_files(dir: &std::path::Path) -> usize {
walkdir::WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path().is_file()
&& e.path()
.extension()
.map(|ext| ext == "rego")
.unwrap_or(false)
/// Count .rego files directly in a directory (non-recursive)
fn count_rego_files_direct(dir: &std::path::Path) -> usize {
std::fs::read_dir(dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
Comment thread
captjt marked this conversation as resolved.
.filter(|e| {
e.path().is_file()
&& e.path()
.extension()
.map(|ext| ext == "rego")
.unwrap_or(false)
})
.count()
})
.count()
.unwrap_or(0)
}

fn validate_rego_namespaces(rulebook_path: &std::path::Path, rulebook_name: &str) -> Result<()> {
let normalized_name = rulebook_name.replace('-', "_");
let base_prefix = format!("cupcake.catalog.{}", normalized_name);

// Validate policies/ directory (policies namespace)
let policies_dir = rulebook_path.join("policies");
let expected_prefix = format!("cupcake.catalog.{}", rulebook_name.replace('-', "_"));
if policies_dir.exists() {
let expected_prefix = format!("{}.policies", base_prefix);
validate_rego_files_in_dir(&policies_dir, &expected_prefix, rulebook_path)?;
}

// Validate helpers/ directory (helpers namespace)
let helpers_dir = rulebook_path.join("helpers");
if helpers_dir.exists() {
let expected_prefix = format!("{}.helpers", base_prefix);
validate_rego_files_in_dir(&helpers_dir, &expected_prefix, rulebook_path)?;
}

// Validate system/ directory (exact system namespace)
let system_dir = rulebook_path.join("system");
if system_dir.exists() {
let expected_package = format!("{}.system", base_prefix);
for entry in walkdir::WalkDir::new(&system_dir) {
let entry = entry?;
if !entry.path().is_file() {
continue;
}
if entry
.path()
.extension()
.map(|ext| ext != "rego")
.unwrap_or(true)
{
continue;
}

let content = std::fs::read_to_string(entry.path())?;
let package_name = extract_package_name(&content);

if let Some(pkg) = package_name {
if pkg != expected_package {
anyhow::bail!(
"System file at {:?} has invalid namespace '{}'. Expected exactly '{}'",
entry
.path()
.strip_prefix(rulebook_path)
.unwrap_or(entry.path()),
pkg,
expected_package
);
}
}
}
}

Ok(())
}

for entry in walkdir::WalkDir::new(&policies_dir) {
/// Validate .rego files in a directory have the expected namespace prefix
fn validate_rego_files_in_dir(
dir: &std::path::Path,
expected_prefix: &str,
rulebook_path: &std::path::Path,
) -> Result<()> {
for entry in walkdir::WalkDir::new(dir) {
let entry = entry?;
if !entry.path().is_file() {
continue;
Expand All @@ -779,30 +839,43 @@ fn validate_rego_namespaces(rulebook_path: &std::path::Path, rulebook_name: &str
}

let content = std::fs::read_to_string(entry.path())?;

// Find package declaration
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("package ") {
let package_name = trimmed.strip_prefix("package ").unwrap_or("").trim();

// Check namespace prefix
if !package_name.starts_with(&expected_prefix) {
anyhow::bail!(
"Policy at {:?} has invalid namespace '{}'. Expected prefix '{}'",
entry.path(),
package_name,
expected_prefix
);
}
break;
let package_name = extract_package_name(&content);

if let Some(pkg) = package_name {
if !pkg.starts_with(expected_prefix) {
anyhow::bail!(
"File at {:?} has invalid namespace '{}'. Expected prefix '{}'",
entry
.path()
.strip_prefix(rulebook_path)
.unwrap_or(entry.path()),
pkg,
expected_prefix
);
}
}
}

Ok(())
}

/// Extract package name from Rego content
fn extract_package_name(content: &str) -> Option<String> {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("package ") {
return Some(
trimmed
.strip_prefix("package ")
.unwrap_or("")
.trim()
.to_string(),
);
}
}
None
}

fn print_validation_results(errors: &[String], warnings: &[String]) {
if errors.is_empty() && warnings.is_empty() {
println!("Rulebook is valid.");
Expand Down
Loading