Skip to content

Commit 7273af4

Browse files
authored
Merge pull request #75 from eqtylab/feat/catalog-restructure
feat: rulebook standard structuring support
2 parents f520db7 + 4eaa962 commit 7273af4

39 files changed

Lines changed: 931 additions & 1261 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: CI - OpenCode Plugin
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'cupcake-plugins/opencode/**'
7+
- '.github/workflows/ci-opencode-plugin.yml'
8+
push:
9+
branches: [main]
10+
paths:
11+
- 'cupcake-plugins/opencode/**'
12+
- '.github/workflows/ci-opencode-plugin.yml'
13+
14+
jobs:
15+
build:
16+
name: Build & Type Check
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: Checkout code
20+
uses: actions/checkout@v4
21+
22+
- name: Setup Bun
23+
uses: oven-sh/setup-bun@v2
24+
with:
25+
bun-version: latest
26+
27+
- name: Install dependencies
28+
working-directory: cupcake-plugins/opencode
29+
run: bun install
30+
31+
- name: Type check
32+
working-directory: cupcake-plugins/opencode
33+
run: bun run typecheck
34+
35+
- name: Build
36+
working-directory: cupcake-plugins/opencode
37+
run: bun run build
38+
39+
- name: Verify output exists
40+
working-directory: cupcake-plugins/opencode
41+
run: |
42+
if [ ! -f dist/cupcake.js ]; then
43+
echo "Error: dist/cupcake.js not found"
44+
exit 1
45+
fi
46+
echo "Build output size: $(wc -c < dist/cupcake.js) bytes"
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
name: Release OpenCode Plugin
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'cupcake-plugins/opencode/**'
8+
workflow_dispatch:
9+
10+
jobs:
11+
build-and-release:
12+
name: Build and Release Plugin
13+
runs-on: ubuntu-latest
14+
permissions:
15+
contents: write
16+
steps:
17+
- name: Checkout code
18+
uses: actions/checkout@v4
19+
20+
- name: Setup Bun
21+
uses: oven-sh/setup-bun@v2
22+
with:
23+
bun-version: latest
24+
25+
- name: Install dependencies
26+
working-directory: cupcake-plugins/opencode
27+
run: bun install
28+
29+
- name: Type check
30+
working-directory: cupcake-plugins/opencode
31+
run: bun run typecheck
32+
33+
- name: Build plugin
34+
working-directory: cupcake-plugins/opencode
35+
run: bun run build
36+
37+
- name: Generate checksum
38+
working-directory: cupcake-plugins/opencode/dist
39+
run: |
40+
sha256sum cupcake.js > cupcake.js.sha256
41+
echo "Generated checksum:"
42+
cat cupcake.js.sha256
43+
44+
- name: Get commit info
45+
id: commit
46+
run: |
47+
echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
48+
echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
49+
50+
- name: Update plugin release
51+
uses: softprops/action-gh-release@v2
52+
with:
53+
tag_name: opencode-plugin-latest
54+
name: OpenCode Plugin (Latest)
55+
body: |
56+
Latest build of the Cupcake OpenCode plugin.
57+
58+
This release is automatically updated when changes are merged to main.
59+
60+
**Build:** ${{ steps.commit.outputs.sha_short }} (${{ steps.commit.outputs.date }})
61+
62+
## Files
63+
- `opencode-plugin.js` - The plugin file
64+
- `opencode-plugin.js.sha256` - SHA256 checksum
65+
66+
## Installation
67+
68+
The plugin is automatically downloaded when you run:
69+
```bash
70+
cupcake init --harness opencode
71+
```
72+
73+
Or manually download:
74+
```bash
75+
curl -fsSL https://github.com/eqtylab/cupcake/releases/download/opencode-plugin-latest/opencode-plugin.js \
76+
-o .opencode/plugin/cupcake.js
77+
```
78+
files: |
79+
cupcake-plugins/opencode/dist/cupcake.js#opencode-plugin.js
80+
cupcake-plugins/opencode/dist/cupcake.js.sha256#opencode-plugin.js.sha256
81+
make_latest: false
82+
prerelease: false

.github/workflows/release.yml

Lines changed: 2 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -163,54 +163,8 @@ jobs:
163163
draft: true
164164
prerelease: ${{ contains(steps.get_version.outputs.version, '-') }}
165165

166-
# Build OpenCode plugin
167-
build-opencode-plugin:
168-
name: Build OpenCode Plugin
169-
needs: create-release
170-
runs-on: ubuntu-latest
171-
steps:
172-
- name: Checkout code
173-
uses: actions/checkout@v4
174-
175-
- name: Setup Node.js
176-
uses: actions/setup-node@v4
177-
with:
178-
node-version: '20'
179-
180-
- name: Install dependencies
181-
working-directory: cupcake-plugins/opencode
182-
run: npm install
183-
184-
- name: Build plugin
185-
working-directory: cupcake-plugins/opencode
186-
run: npm run build
187-
188-
- name: Generate checksum
189-
working-directory: cupcake-plugins/opencode/dist
190-
run: |
191-
sha256sum cupcake.js > cupcake.js.sha256
192-
echo "Generated checksum:"
193-
cat cupcake.js.sha256
194-
195-
- name: Upload plugin to release
196-
uses: actions/upload-release-asset@v1
197-
env:
198-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
199-
with:
200-
upload_url: ${{ needs.create-release.outputs.upload_url }}
201-
asset_path: cupcake-plugins/opencode/dist/cupcake.js
202-
asset_name: opencode-plugin.js
203-
asset_content_type: application/javascript
204-
205-
- name: Upload plugin checksum
206-
uses: actions/upload-release-asset@v1
207-
env:
208-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
209-
with:
210-
upload_url: ${{ needs.create-release.outputs.upload_url }}
211-
asset_path: cupcake-plugins/opencode/dist/cupcake.js.sha256
212-
asset_name: opencode-plugin.js.sha256
213-
asset_content_type: text/plain
166+
# NOTE: OpenCode plugin is released separately via release-opencode-plugin.yml
167+
# It uses a dedicated release tag (opencode-plugin-latest) that is updated on merges to main
214168

215169
# Upload install scripts to release
216170
upload-install-scripts:

cupcake-cli/src/catalog_cli.rs

Lines changed: 119 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,12 @@ async fn execute_lint(path: &std::path::Path) -> Result<()> {
680680
errors.push(format!("Manifest validation failed: {e}"));
681681
}
682682

683+
// Check for system/evaluate.rego at rulebook root (shared entrypoint)
684+
let system_eval = path.join("system").join("evaluate.rego");
685+
if !system_eval.exists() {
686+
errors.push("Missing system/evaluate.rego at rulebook root".to_string());
687+
}
688+
683689
// Check policies exist for declared harnesses
684690
let policies_dir = path.join("policies");
685691
if !policies_dir.exists() {
@@ -692,27 +698,17 @@ async fn execute_lint(path: &std::path::Path) -> Result<()> {
692698
continue;
693699
}
694700

695-
// Check for system/evaluate.rego
696-
let system_eval = harness_dir.join("system").join("evaluate.rego");
697-
if !system_eval.exists() {
698-
errors.push(format!(
699-
"Missing system/evaluate.rego for harness: {harness}"
700-
));
701-
}
702-
703-
// Check that at least some .rego files exist
704-
let rego_files = count_rego_files(&harness_dir);
701+
// Check that harness directory has at least one .rego file directly
702+
let rego_files = count_rego_files_direct(&harness_dir);
705703
if rego_files == 0 {
706-
errors.push(format!("No .rego files found for harness: {harness}"));
704+
errors.push(format!("No .rego policy files in policies/{}/", harness));
707705
}
708706
}
709707
}
710708

711-
// Validate Rego namespaces
712-
if policies_dir.exists() {
713-
if let Err(e) = validate_rego_namespaces(path, &manifest.metadata.name) {
714-
errors.push(format!("Namespace validation failed: {e}"));
715-
}
709+
// Validate Rego namespaces (policies, helpers, and system)
710+
if let Err(e) = validate_rego_namespaces(path, &manifest.metadata.name) {
711+
errors.push(format!("Namespace validation failed: {}", e));
716712
}
717713

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

748-
fn count_rego_files(dir: &std::path::Path) -> usize {
749-
walkdir::WalkDir::new(dir)
750-
.into_iter()
751-
.filter_map(|e| e.ok())
752-
.filter(|e| {
753-
e.path().is_file()
754-
&& e.path()
755-
.extension()
756-
.map(|ext| ext == "rego")
757-
.unwrap_or(false)
744+
/// Count .rego files directly in a directory (non-recursive)
745+
fn count_rego_files_direct(dir: &std::path::Path) -> usize {
746+
std::fs::read_dir(dir)
747+
.map(|entries| {
748+
entries
749+
.filter_map(|e| e.ok())
750+
.filter(|e| {
751+
e.path().is_file()
752+
&& e.path()
753+
.extension()
754+
.map(|ext| ext == "rego")
755+
.unwrap_or(false)
756+
})
757+
.count()
758758
})
759-
.count()
759+
.unwrap_or(0)
760760
}
761761

762762
fn validate_rego_namespaces(rulebook_path: &std::path::Path, rulebook_name: &str) -> Result<()> {
763+
let normalized_name = rulebook_name.replace('-', "_");
764+
let base_prefix = format!("cupcake.catalog.{}", normalized_name);
765+
766+
// Validate policies/ directory (policies namespace)
763767
let policies_dir = rulebook_path.join("policies");
764-
let expected_prefix = format!("cupcake.catalog.{}", rulebook_name.replace('-', "_"));
768+
if policies_dir.exists() {
769+
let expected_prefix = format!("{}.policies", base_prefix);
770+
validate_rego_files_in_dir(&policies_dir, &expected_prefix, rulebook_path)?;
771+
}
772+
773+
// Validate helpers/ directory (helpers namespace)
774+
let helpers_dir = rulebook_path.join("helpers");
775+
if helpers_dir.exists() {
776+
let expected_prefix = format!("{}.helpers", base_prefix);
777+
validate_rego_files_in_dir(&helpers_dir, &expected_prefix, rulebook_path)?;
778+
}
779+
780+
// Validate system/ directory (exact system namespace)
781+
let system_dir = rulebook_path.join("system");
782+
if system_dir.exists() {
783+
let expected_package = format!("{}.system", base_prefix);
784+
for entry in walkdir::WalkDir::new(&system_dir) {
785+
let entry = entry?;
786+
if !entry.path().is_file() {
787+
continue;
788+
}
789+
if entry
790+
.path()
791+
.extension()
792+
.map(|ext| ext != "rego")
793+
.unwrap_or(true)
794+
{
795+
continue;
796+
}
797+
798+
let content = std::fs::read_to_string(entry.path())?;
799+
let package_name = extract_package_name(&content);
800+
801+
if let Some(pkg) = package_name {
802+
if pkg != expected_package {
803+
anyhow::bail!(
804+
"System file at {:?} has invalid namespace '{}'. Expected exactly '{}'",
805+
entry
806+
.path()
807+
.strip_prefix(rulebook_path)
808+
.unwrap_or(entry.path()),
809+
pkg,
810+
expected_package
811+
);
812+
}
813+
}
814+
}
815+
}
816+
817+
Ok(())
818+
}
765819

766-
for entry in walkdir::WalkDir::new(&policies_dir) {
820+
/// Validate .rego files in a directory have the expected namespace prefix
821+
fn validate_rego_files_in_dir(
822+
dir: &std::path::Path,
823+
expected_prefix: &str,
824+
rulebook_path: &std::path::Path,
825+
) -> Result<()> {
826+
for entry in walkdir::WalkDir::new(dir) {
767827
let entry = entry?;
768828
if !entry.path().is_file() {
769829
continue;
@@ -779,30 +839,43 @@ fn validate_rego_namespaces(rulebook_path: &std::path::Path, rulebook_name: &str
779839
}
780840

781841
let content = std::fs::read_to_string(entry.path())?;
782-
783-
// Find package declaration
784-
for line in content.lines() {
785-
let trimmed = line.trim();
786-
if trimmed.starts_with("package ") {
787-
let package_name = trimmed.strip_prefix("package ").unwrap_or("").trim();
788-
789-
// Check namespace prefix
790-
if !package_name.starts_with(&expected_prefix) {
791-
anyhow::bail!(
792-
"Policy at {:?} has invalid namespace '{}'. Expected prefix '{}'",
793-
entry.path(),
794-
package_name,
795-
expected_prefix
796-
);
797-
}
798-
break;
842+
let package_name = extract_package_name(&content);
843+
844+
if let Some(pkg) = package_name {
845+
if !pkg.starts_with(expected_prefix) {
846+
anyhow::bail!(
847+
"File at {:?} has invalid namespace '{}'. Expected prefix '{}'",
848+
entry
849+
.path()
850+
.strip_prefix(rulebook_path)
851+
.unwrap_or(entry.path()),
852+
pkg,
853+
expected_prefix
854+
);
799855
}
800856
}
801857
}
802858

803859
Ok(())
804860
}
805861

862+
/// Extract package name from Rego content
863+
fn extract_package_name(content: &str) -> Option<String> {
864+
for line in content.lines() {
865+
let trimmed = line.trim();
866+
if trimmed.starts_with("package ") {
867+
return Some(
868+
trimmed
869+
.strip_prefix("package ")
870+
.unwrap_or("")
871+
.trim()
872+
.to_string(),
873+
);
874+
}
875+
}
876+
None
877+
}
878+
806879
fn print_validation_results(errors: &[String], warnings: &[String]) {
807880
if errors.is_empty() && warnings.is_empty() {
808881
println!("Rulebook is valid.");

0 commit comments

Comments
 (0)