Skip to content

Commit e245317

Browse files
authored
feat!: add OPA Rego v1 compatibility check (INT-331) (#35)
## Summary This PR adds a Rego v1 compatibility check to the action, ensuring policy files conform to OPA v1 / Rego v1 syntax before tests are run. ### What changed - **New input `v1_compatible_check`** (default: `true`) — when enabled, runs `opa check --v1-compatible` against all Rego files in `path` before executing tests. If any files use v0-only syntax (missing `if`, `contains`, or `import rego.v1`), the action fails immediately with OPA's error output identifying the offending files and line numbers. - **Test runner flag is now driven by the same input** — when `v1_compatible_check: true`, `opa test` also runs with `--v1-compatible` so the syntax validation and test execution are consistent. When `false`, both revert to `--v0-compatible`. - **All `examples/` updated to Rego v1 syntax** — replaced `import future.keywords.*` with `import rego.v1`, added `if` to rule bodies, and added `contains` to partial set rules across all 8 policies and 7 test files. All 46 tests still pass. - **README updated** — new input documented in the inputs table and How It Works section. If the new V1 check fails, the report output will display as: <img width="570" height="856" alt="Screenshot 2026-04-20 at 11 25 28 AM" src="https://github.com/user-attachments/assets/ff67ab67-b827-4490-9843-9005e8976dc9" /> ### ⚠️ Breaking change This will be a **major version bump**. The `v1_compatible_check` input defaults to `true`, which means existing users with v0 syntax policies will see their workflows fail. To preserve previous behavior, set: ```yaml - uses: masterpointio/github-action-opa-rego-test@main with: path: ./policies v1_compatible_check: false ` `` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added optional Rego v1 compatibility check to the action (enabled by default). When enabled the action validates all policy files and fails early on v0-only syntax. * Action now surfaces a dedicated v1-compatibility failure section in its output. * **Documentation** * README updated with a new `v1_compatible_check` input to control this behavior and adjusted workflow step ordering. * **Examples** * Updated example policies and tests to Rego v1 syntax. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent ec618c3 commit e245317

21 files changed

Lines changed: 321 additions & 157 deletions

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ In the example below, all `_test.rego` files' location are valid and will be exe
9191
| `report_untested_files` | Check & report Rego files without corresponding test files | No | `false` |
9292
| `opa_version` | Version of the OPA CLI to use. | No | `1.4.2` |
9393
| `opa_static` | Whether to use the static binary for OPA installation. use. | No | `false` |
94+
| `v1_compatible_check` | Flag to run `opa check --v1-compatible` against all Rego files in `path`. Fails the action with a clear error if any files use Rego v0-only syntax. Set to `false` to disable. | No | `true` |
9495
| `indicate_source_message` | Flag to comment the origins watermark (this repository) of the GitHub Action in the PR comment. | No | `true` |
9596

9697
### Outputs
@@ -105,12 +106,13 @@ In the example below, all `_test.rego` files' location are valid and will be exe
105106
This GitHub Action automates the process of testing OPA (Open Policy Agent) Rego policies and generating coverage reports. Here's a breakdown of its operation:
106107

107108
1. Setup: The action begins by setting up OPA using the open-policy-agent/setup-opa@v2 action, ensuring the necessary tools are available.
108-
2. Run OPA Tests: It executes `opa test` on all .rego files in the specified directory (default is the root directory). The test results are captured and stored as an output.
109-
3. Run OPA Coverage Tests: Enabled by default but optional, the action performs coverage tests on each .rego file that has a corresponding \_test.rego file. This step identifies which parts of your policies are covered by tests.
110-
4. Find Untested Files: Optionally if enabled, it can identify Rego files that don't have corresponding test files, helping you maintain comprehensive test coverage.
111-
5. Parse and Format Results: A custom TypeScript script (index.ts) processes the raw test and coverage outputs. It parses the results into a structured format and generates a user-friendly summary.
112-
6. Generate PR Comment: The formatted results are used to create or update a comment on the pull request.
113-
7. Fail the Action if Tests Fail: If any tests fail, the action is marked as failed, which can be used to block PR merges or trigger other workflows.
109+
2. Find Untested Files: Optionally if enabled, it can identify Rego files that don't have corresponding test files, helping you maintain comprehensive test coverage.
110+
3. Rego v1 Compatibility Check (optional, default enabled): Runs `opa check --v1-compatible` against all Rego files in the path. If any files use Rego v0-only syntax, the action fails immediately with OPA's error output identifying the offending files. This check can be disabled by setting `v1_compatible_check: false`.
111+
4. Run OPA Tests: It executes `opa test` on all .rego files in the specified directory (default is the root directory). The test results are captured and stored as an output.
112+
5. Run OPA Coverage Tests: Enabled by default but optional, the action performs coverage tests on each .rego file that has a corresponding \_test.rego file. This step identifies which parts of your policies are covered by tests.
113+
6. Parse and Format Results: A custom TypeScript script (index.ts) processes the raw test and coverage outputs. It parses the results into a structured format and generates a user-friendly summary.
114+
7. Generate PR Comment: The formatted results are used to create or update a comment on the pull request.
115+
8. Fail the Action if Tests Fail: If any tests fail, the action is marked as failed, which can be used to block PR merges or trigger other workflows.
114116

115117
![Masterpoint OPA Rego Test Action Diagram](https://lucid.app/publicSegments/view/60bf898e-2640-475f-b130-2a70d317a65d/image.png)
116118

action.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ inputs:
4545
description: "Whether to use the static binary. Default is false."
4646
required: false
4747
default: "false"
48+
v1_compatible_check:
49+
description: Flag to run OPA v1 compatibility check (`opa check --v1-compatible`) on all Rego files in the path. Fails the action if any files are not Rego v1 compatible. Default of true.
50+
required: false
51+
default: "true"
4852
indicate_source_message:
4953
description: Flag to comment the origins (this repository) of the GitHub Action in the PR comment. Default of true.
5054
required: false
@@ -102,6 +106,7 @@ runs:
102106
no_test_files: ${{ steps.find-no-test.outputs.no_test_files }}
103107
pr_comment_title: ${{ inputs.pr_comment_title }}
104108
run_coverage_report: ${{ inputs.run_coverage_report }}
109+
v1_compatible_check: ${{ inputs.v1_compatible_check }}
105110
indicate_source_message: ${{ inputs.indicate_source_message }}
106111
path: ${{ inputs.path }}
107112
test_file_postfix: ${{ inputs.test_file_postfix }}

dist/index.js

Lines changed: 104 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -26288,60 +26288,87 @@ function main() {
2628826288
const reportNoTestFiles = process.env.report_untested_files === "true";
2628926289
const noTestFiles = process.env.no_test_files;
2629026290
const runCoverageReport = process.env.run_coverage_report === "true";
26291+
const useV1Compatible = process.env.v1_compatible_check !== "false";
2629126292
const path = process.env.path;
2629226293
const test_file_postfix = process.env.test_file_postfix;
2629326294
if (!path || !test_file_postfix) {
2629426295
throw new Error("Both 'path' and 'test_file_postfix' environment variables must be set.");
2629526296
}
26297+
let v1CheckFailed = false;
26298+
let v1CheckError = "";
2629626299
let opaOutput = "";
2629726300
let opaError = "";
2629826301
let exitCode = 0;
2629926302
let coverageOutput;
26300-
if (test_mode === "directory") {
26301-
({
26302-
output: opaOutput,
26303-
error: opaError,
26304-
exitCode: exitCode,
26305-
coverageOutput: coverageOutput,
26306-
} = yield (0, opaCommands_1.executeOpaTestByDirectory)(path, true));
26303+
if (useV1Compatible) {
26304+
console.log(`Running OPA v1 compatibility check on: ${path}`);
26305+
const { output: v1Output, error: v1Error, exitCode: v1ExitCode, } = yield (0, opaCommands_1.executeOpaV1CompatibilityCheck)(path);
26306+
if (v1ExitCode !== 0) {
26307+
v1CheckFailed = true;
26308+
v1CheckError = [v1Output, v1Error].filter(Boolean).join("\n");
26309+
console.log("OPA v1 compatibility check failed.");
26310+
}
26311+
else {
26312+
console.log("OPA v1 compatibility check passed.");
26313+
}
2630726314
}
2630826315
else {
26309-
({
26310-
output: opaOutput,
26311-
error: opaError,
26312-
exitCode: exitCode,
26313-
coverageOutput: coverageOutput,
26314-
} = yield (0, opaCommands_1.executeIndividualOpaTests)(path, test_file_postfix, true));
26316+
console.log("OPA v1 compatibility check skipped.");
2631526317
}
26316-
let parsedResults = (0, testResultProcessing_1.processTestResults)(JSON.parse(opaOutput));
26318+
let parsedResults = [];
2631726319
let coverageResults = [];
26318-
if (runCoverageReport) {
26319-
if (coverageOutput) {
26320-
coverageResults = (0, testResultProcessing_1.processCoverageReport)(JSON.parse(coverageOutput));
26320+
if (!v1CheckFailed) {
26321+
if (test_mode === "directory") {
26322+
({
26323+
output: opaOutput,
26324+
error: opaError,
26325+
exitCode: exitCode,
26326+
coverageOutput: coverageOutput,
26327+
} = yield (0, opaCommands_1.executeOpaTestByDirectory)(path, true, useV1Compatible));
26328+
}
26329+
else {
26330+
({
26331+
output: opaOutput,
26332+
error: opaError,
26333+
exitCode: exitCode,
26334+
coverageOutput: coverageOutput,
26335+
} = yield (0, opaCommands_1.executeIndividualOpaTests)(path, test_file_postfix, true, useV1Compatible));
26336+
}
26337+
parsedResults = (0, testResultProcessing_1.processTestResults)(JSON.parse(opaOutput));
26338+
if (runCoverageReport) {
26339+
if (coverageOutput) {
26340+
coverageResults = (0, testResultProcessing_1.processCoverageReport)(JSON.parse(coverageOutput));
26341+
}
26342+
}
26343+
// At the end of the table, if the reportNoTestFile flag is on, add all the files that didn't have an associated test with it.
26344+
if (noTestFiles && reportNoTestFiles) {
26345+
const noTestFileResults = noTestFiles
26346+
.split("\n")
26347+
.map((file) => ({
26348+
file: file.trim(),
26349+
status: "NO TESTS",
26350+
passed: 0,
26351+
total: 0,
26352+
details: [],
26353+
}));
26354+
parsedResults = [...parsedResults, ...noTestFileResults];
2632126355
}
26322-
}
26323-
// At the end of the table, if the reportNoTestFile flag is on, add all the files that didn't have an associated test with it.
26324-
if (noTestFiles && reportNoTestFiles) {
26325-
const noTestFileResults = noTestFiles
26326-
.split("\n")
26327-
.map((file) => ({
26328-
file: file.trim(),
26329-
status: "NO TESTS",
26330-
passed: 0,
26331-
total: 0,
26332-
details: [],
26333-
}));
26334-
parsedResults = [...parsedResults, ...noTestFileResults];
2633526356
}
2633626357
let formattedOutput = (0, formatResults_1.formatResults)(parsedResults, coverageResults, runCoverageReport);
2633726358
if (formattedOutput === "") {
2633826359
formattedOutput = errorString;
2633926360
}
26361+
if (v1CheckFailed) {
26362+
formattedOutput += `\n\n## ⛔️ Rego v1 Compatibility Check Failed\n\nOne or more Rego files are not v1 compatible.\n\n\`\`\`\n${v1CheckError}\n\`\`\``;
26363+
}
2634026364
// This is the output that will be used in the GitHub Pull Request comment.
2634126365
core.setOutput("parsed_results", formattedOutput);
2634226366
const testsFailed = parsedResults.some((result) => result.status === "FAIL");
2634326367
core.setOutput("tests_failed", testsFailed.toString());
26344-
if (testsFailed) {
26368+
if (v1CheckFailed) {
26369+
core.setFailed(`OPA v1 compatibility check failed. One or more Rego files are not v1 compatible.\n${v1CheckError}`);
26370+
}
26371+
else if (testsFailed) {
2634526372
core.setFailed(`One or more OPA tests failed: ${opaError}`);
2634626373
}
2634726374
}
@@ -26401,13 +26428,46 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
2640126428
return (mod && mod.__esModule) ? mod : { "default": mod };
2640226429
};
2640326430
Object.defineProperty(exports, "__esModule", ({ value: true }));
26431+
exports.executeOpaV1CompatibilityCheck = executeOpaV1CompatibilityCheck;
2640426432
exports.executeOpaTestByDirectory = executeOpaTestByDirectory;
2640526433
exports.executeIndividualOpaTests = executeIndividualOpaTests;
2640626434
const exec = __importStar(__nccwpck_require__(1514));
2640726435
const path_1 = __importDefault(__nccwpck_require__(1017));
2640826436
const opaV0CompatibleFlag = "--v0-compatible"; // https://www.openpolicyagent.org/docs/latest/v0-compatibility/
26437+
const opaV1CompatibleFlag = "--v1-compatible";
26438+
/**
26439+
* Run `opa check --v1-compatible` against a directory to validate all Rego files
26440+
* are compatible with OPA v1 / Rego v1 syntax.
26441+
* @param path - The directory containing Rego files to validate.
26442+
* @returns An object containing stdout output, error output, and exit code.
26443+
*/
26444+
function executeOpaV1CompatibilityCheck(path) {
26445+
return __awaiter(this, void 0, void 0, function* () {
26446+
let opaOutput = "";
26447+
let opaError = "";
26448+
const exitCode = yield exec.exec("opa", ["check", path, opaV1CompatibleFlag], {
26449+
listeners: {
26450+
stdout: (data) => {
26451+
opaOutput += data.toString();
26452+
},
26453+
stderr: (data) => {
26454+
opaError += data.toString();
26455+
},
26456+
},
26457+
ignoreReturnCode: true,
26458+
});
26459+
return { output: opaOutput, error: opaError, exitCode };
26460+
});
26461+
}
26462+
/**
26463+
* Run OPA tests on all files in the specified directory.
26464+
* @param path - The directory containing Rego files to test.
26465+
* @param runCoverageReport - Whether to run coverage report (default: false).
26466+
* @param useV1Compatible - Whether to run tests using --v1-compatible flag (default: false).
26467+
* @returns An object containing the test results, error messages, and exit codes.
26468+
*/
2640926469
function executeOpaTestByDirectory(path_2) {
26410-
return __awaiter(this, arguments, void 0, function* (path, runCoverageReport = false) {
26470+
return __awaiter(this, arguments, void 0, function* (path, runCoverageReport = false, useV1Compatible = false) {
2641126471
let opaOutput = "";
2641226472
let opaError = "";
2641326473
let opaCoverageOutput = "";
@@ -26424,7 +26484,10 @@ function executeOpaTestByDirectory(path_2) {
2642426484
},
2642526485
ignoreReturnCode: true,
2642626486
};
26427-
exitCode = yield exec.exec("opa", ["test", path, "--format=json", opaV0CompatibleFlag], options);
26487+
const compatFlag = useV1Compatible
26488+
? opaV1CompatibleFlag
26489+
: opaV0CompatibleFlag;
26490+
exitCode = yield exec.exec("opa", ["test", path, "--format=json", compatFlag], options);
2642826491
if (runCoverageReport) {
2642926492
const coverageOptions = {
2643026493
listeners: {
@@ -26437,7 +26500,7 @@ function executeOpaTestByDirectory(path_2) {
2643726500
},
2643826501
ignoreReturnCode: true,
2643926502
};
26440-
coverageExitCode = yield exec.exec("opa", ["test", path, "--format=json", "--coverage", opaV0CompatibleFlag], coverageOptions);
26503+
coverageExitCode = yield exec.exec("opa", ["test", path, "--format=json", "--coverage", compatFlag], coverageOptions);
2644126504
}
2644226505
else {
2644326506
console.log("Coverage reporting skipped due to runCoverageReport flag set to false");
@@ -26454,10 +26517,11 @@ function executeOpaTestByDirectory(path_2) {
2645426517
* @param basePath - The base path to search for test files.
2645526518
* @param testFilePostfix - The postfix of the test files to look for (e.g., "_test").
2645626519
* @param runCoverageReport - Whether to run coverage report (default: false).
26520+
* @param useV1Compatible - Whether to run tests using --v1-compatible flag (default: false).
2645726521
* @returns An object containing the test results, error messages, and exit codes.
2645826522
*/
2645926523
function executeIndividualOpaTests(basePath_1, testFilePostfix_1) {
26460-
return __awaiter(this, arguments, void 0, function* (basePath, testFilePostfix, runCoverageReport = false) {
26524+
return __awaiter(this, arguments, void 0, function* (basePath, testFilePostfix, runCoverageReport = false, useV1Compatible = false) {
2646126525
const allTestResults = [];
2646226526
let opaError = "";
2646326527
let exitCode = 0;
@@ -26476,6 +26540,9 @@ function executeIndividualOpaTests(basePath_1, testFilePostfix_1) {
2647626540
opaError += findStderr + "\n";
2647726541
exitCode = 1;
2647826542
}
26543+
const compatFlag = useV1Compatible
26544+
? opaV1CompatibleFlag
26545+
: opaV0CompatibleFlag;
2647926546
const testFiles = findStdout.trim().split("\n").filter(Boolean);
2648026547
for (const testFile of testFiles) {
2648126548
const base = path_1.default.basename(testFile, `${testFilePostfix}.rego`);
@@ -26505,7 +26572,7 @@ function executeIndividualOpaTests(basePath_1, testFilePostfix_1) {
2650526572
// -------- Running OPA test --------
2650626573
let testOutput = "";
2650726574
let testErrMsg = "";
26508-
const testExitCode = yield exec.exec("opa", ["test", testFile, implFile, "--format=json", opaV0CompatibleFlag], {
26575+
const testExitCode = yield exec.exec("opa", ["test", testFile, implFile, "--format=json", compatFlag], {
2650926576
listeners: {
2651026577
stdout: (b) => (testOutput += b.toString()),
2651126578
stderr: (b) => (testErrMsg += b.toString()),
@@ -26529,14 +26596,7 @@ function executeIndividualOpaTests(basePath_1, testFilePostfix_1) {
2652926596
if (runCoverageReport) {
2653026597
let covOut = "";
2653126598
let covErr = "";
26532-
const covExit = yield exec.exec("opa", [
26533-
"test",
26534-
testFile,
26535-
implFile,
26536-
"--coverage",
26537-
"--format=json",
26538-
opaV0CompatibleFlag,
26539-
], {
26599+
const covExit = yield exec.exec("opa", ["test", testFile, implFile, "--coverage", "--format=json", compatFlag], {
2654026600
listeners: {
2654126601
stdout: (b) => (covOut += b.toString()),
2654226602
stderr: (b) => (covErr += b.toString()),

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/cancel-in-progress-runs.rego

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package spacelift
22

3+
import rego.v1
4+
35
# The push policy can be used to have the new run pre-empt any runs that are
46
# currently in progress. The input document includes the in_progress key, which
57
# contains an array of runs that are currently either still queued or are awaiting
68
# human confirmation. You can use it in conjunction with the cancel rule like this:
7-
cancel[run.id] {
9+
cancel contains run.id if {
810
run := input.in_progress[_]
911
run.type == "PROPOSED"
1012
run.state == "QUEUED"

examples/do-not-delete-stateful-resources.rego

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
package spacelift
22

3-
import future.keywords.in
3+
import rego.v1
44

55
# This policy is a plan policy, it will validate the resources during the plan phase.
66
# More details at: https://docs.spacelift.io/concepts/policy/terraform-plan-policy
77
# The "deny" rule fires when a specified resource is being deleted.
88
# The result is a formatted message with the address of the offending resource.
99

10-
deny[sprintf(message, [resource.address])] {
10+
deny contains sprintf(message, [resource.address]) if {
1111
# Define the error message format
1212
message := "do not delete %s"
1313

examples/drift-detection.rego

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package spacelift
22

3-
slack[{"channel_id": "C000000"}] {
3+
import rego.v1
4+
5+
slack contains {"channel_id": "C000000"} if {
46
# Checking if drift detection is present in the run update
57
input.run_updated.run.drift_detection
68

0 commit comments

Comments
 (0)