├── .github ├── CODEOWNERS ├── pull_request_template.md └── workflows │ ├── lint.yml │ ├── release-please.yml │ ├── test.yml │ └── trunk-upgrade.yml ├── .gitignore ├── .jest └── setupTests.ts ├── .trunk ├── .gitignore ├── configs │ ├── .markdownlint.yaml │ └── .yamllint.yaml └── trunk.yaml ├── .vscode └── launch.json ├── CHANGELOG.md ├── README.md ├── __tests__ ├── index.test.ts ├── mockResults.ts ├── sample_coverage_multiple_uncovered_lines.txt ├── sample_coverage_output.txt └── sample_test_output.txt ├── action.yml ├── assets ├── banner-pr-comment-example.png ├── opa-logo.png ├── readme-example-1.png ├── readme-example-2.png ├── readme-example-3.png ├── readme-test-results.png └── test-file-structure-example.png ├── dist ├── index.js ├── index.js.map └── sourcemap-register.js ├── examples ├── README.md ├── cancel-in-progress-runs.rego ├── do-not-delete-stateful-resources.rego ├── drift-detection.rego ├── enforce-module-use-policy.rego ├── enforce-password-length.rego ├── ignore-changes-outside-root.rego ├── notification-stack-failure-origins.rego ├── readers-writers-admins-teams.rego ├── tests │ ├── cancel-in-progress-runs_test.rego │ ├── do-not-delete-stateful-resources_test.rego │ ├── enforce-module-use-policy_test.rego │ ├── enforce-password-length_test.rego │ ├── ignore-changes-outside-root_test.rego │ ├── notification-stack-failure-origins_test.rego │ ├── readers-writers-admins-teams_test.rego │ └── track-using-labels_test.rego └── track-using-labels.rego ├── package-lock.json ├── package.json ├── src ├── formatResults.ts ├── index.ts ├── interfaces.ts ├── opaCommands.ts └── testResultProcessing.ts └── tsconfig.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Use this file to define individuals or teams that are responsible for code in a repository. 2 | # Read more: 3 | # 4 | # Order is important: the last matching pattern takes the most precedence 5 | 6 | # These owners will be the default owners for everything 7 | * @masterpointio/masterpoint-open-source 8 | * @oycyc 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Describe your changes 2 | 3 | ## Reminder: 4 | 5 | - When the PR is ready, be sure to run `npm run build` to compile into the distribution `/dist` folder, which is the source code that the Action uses. 6 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | concurrency: 4 | group: lint-${{ github.head_ref || github.run_id }} 5 | cancel-in-progress: true 6 | 7 | on: pull_request 8 | 9 | permissions: 10 | actions: read 11 | checks: write 12 | contents: read 13 | pull-requests: read 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Check out Git repository 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | - name: Trunk Check 22 | uses: trunk-io/trunk-action@4d5ecc89b2691705fd08c747c78652d2fc806a94 # v1.1.19 23 | 24 | conventional-title: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: Create Release via Google Release Please 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: googleapis/release-please-action@7987652d64b4581673a76e33ad5e98e3dd56832f # 4.1.3 17 | with: 18 | release-type: simple 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: pull_request 4 | 5 | permissions: 6 | contents: read 7 | checks: write 8 | pull-requests: write 9 | 10 | jobs: 11 | test-typescript: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Setup Node 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 22 20 | cache: npm 21 | - run: npm ci 22 | - run: npm run build --if-present 23 | - run: npm test 24 | - name: Jest Coverage Report 25 | uses: ArtiomTr/jest-coverage-report-action@c026e98ae079f4b0b027252c8e957f5ebd420610 # v2.3.0 26 | 27 | test-action-on-itself: 28 | name: Test the GitHub Action on itself 29 | runs-on: ubuntu-latest 30 | 31 | steps: 32 | - name: Checkout 33 | id: checkout 34 | uses: actions/checkout@v4 35 | 36 | - name: Test Local Action (Individual File Mode) 37 | id: test-action-opa-files 38 | uses: ./ 39 | with: 40 | path: ./examples 41 | test_mode: file 42 | report_untested_files: true 43 | pr_comment_title: Below is the Action testing on itself with this PR's source code against policies in `/examples` file by file. Confirm it is as expected. 44 | 45 | - name: Test Local Action (Directory Package Mode) 46 | if: (success() || failure()) 47 | id: test-action-opa-package 48 | uses: ./ 49 | with: 50 | path: ./examples 51 | test_mode: directory 52 | report_untested_files: true 53 | pr_comment_title: Below is the Action testing on itself with this PR's source code against `/examples` entire package directory. Confirm it is as expected. 54 | -------------------------------------------------------------------------------- /.github/workflows/trunk-upgrade.yml: -------------------------------------------------------------------------------- 1 | name: Trunk Upgrade 2 | 3 | on: 4 | schedule: 5 | # On the first day of every month @ 8am 6 | - cron: 0 8 1 * * 7 | workflow_dispatch: {} 8 | 9 | permissions: read-all 10 | 11 | jobs: 12 | trunk-upgrade: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | # For trunk to create PRs 16 | contents: write 17 | pull-requests: write 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 #v4.1.7 21 | 22 | - name: Upgrade 23 | uses: trunk-io/trunk-action/upgrade@86b68ffae610a05105e90b1f52ad8c549ef482c2 #v1.1.16 24 | with: 25 | reviewers: "@masterpointio/masterpoint-internal" 26 | prefix: "chore: " 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.jest/setupTests.ts: -------------------------------------------------------------------------------- 1 | // Set up the environment for the tests 2 | // All these values can be empty because in the tests, we are testing the functions directly. 3 | process.env.test_result = "mock"; 4 | -------------------------------------------------------------------------------- /.trunk/.gitignore: -------------------------------------------------------------------------------- 1 | *out 2 | *logs 3 | *actions 4 | *notifications 5 | *tools 6 | plugins 7 | user_trunk.yaml 8 | user.yaml 9 | tmp 10 | -------------------------------------------------------------------------------- /.trunk/configs/.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # Prettier friendly markdownlint config (all formatting rules disabled) 2 | extends: markdownlint/style/prettier 3 | -------------------------------------------------------------------------------- /.trunk/configs/.yamllint.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | quoted-strings: 3 | required: only-when-needed 4 | extra-allowed: ["{|}"] 5 | key-duplicates: {} 6 | octal-values: 7 | forbid-implicit-octal: true 8 | -------------------------------------------------------------------------------- /.trunk/trunk.yaml: -------------------------------------------------------------------------------- 1 | # This file controls the behavior of Trunk: https://docs.trunk.io/cli 2 | # To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml 3 | version: 0.1 4 | cli: 5 | version: 1.22.15 6 | # Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) 7 | plugins: 8 | sources: 9 | - id: trunk 10 | ref: v1.6.8 11 | uri: https://github.com/trunk-io/plugins 12 | # Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) 13 | runtimes: 14 | enabled: 15 | - node@18.20.5 16 | - python@3.10.8 17 | # This is the section where you manage your linters. (https://docs.trunk.io/check/configuration) 18 | lint: 19 | ignore: 20 | - linters: [ALL] 21 | paths: 22 | # Ignore linting the distribution packaged folder 23 | - dist/** 24 | - .github/pull_request_template.md 25 | enabled: 26 | - actionlint@1.7.7 27 | - checkov@3.2.413 28 | - git-diff-check 29 | - markdownlint@0.44.0 30 | - osv-scanner@2.0.2 31 | - oxipng@9.1.5 32 | - prettier@3.5.3 33 | - trivy@0.61.1 34 | - trufflehog@3.88.26 35 | - yamllint@1.37.0 36 | actions: 37 | enabled: 38 | - trunk-announce 39 | - trunk-check-pre-push 40 | - trunk-fmt-pre-commit 41 | - trunk-upgrade-available 42 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "configurations": [ 4 | { 5 | "name": "TS-Node", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeExecutable": "npx", 9 | "runtimeArgs": [ 10 | "ts-node", 11 | "./src/index.ts" 12 | // "--transpile-only", 13 | // "--esm" 14 | ], 15 | // "program": "${file}", 16 | // "program": "${workspaceRoot}/src/index.ts", 17 | // "cwd": "${workspaceRoot}", 18 | "internalConsoleOptions": "openOnSessionStart", 19 | "skipFiles": ["/**", "node_modules/**"], 20 | "env": { 21 | "path": "./examples", 22 | "test_file_postfix": "_test", 23 | "test_mode": "directory" 24 | } 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [2.0.0](https://github.com/masterpointio/github-action-opa-rego-test/compare/v1.2.0...v2.0.0) (2025-05-30) 4 | 5 | 6 | ### ⚠ BREAKING CHANGES 7 | 8 | * **ts:** use @actions/exec instead of bash script for OPA commands, allow testing entire directory vs file by file ([#27](https://github.com/masterpointio/github-action-opa-rego-test/issues/27)) 9 | 10 | ### Bug Fixes 11 | 12 | * **ts:** coverage only if it passed ([#30](https://github.com/masterpointio/github-action-opa-rego-test/issues/30)) ([7e4e69c](https://github.com/masterpointio/github-action-opa-rego-test/commit/7e4e69cb57b96bc5aa401772a4fb3b7911251c77)) 13 | 14 | 15 | ### Code Refactoring 16 | 17 | * **ts:** use @actions/exec instead of bash script for OPA commands, allow testing entire directory vs file by file ([#27](https://github.com/masterpointio/github-action-opa-rego-test/issues/27)) ([9eecaf7](https://github.com/masterpointio/github-action-opa-rego-test/commit/9eecaf74d47ca3976fee01bbcb5a5b724ebc2d0d)) 18 | 19 | ## [1.2.0](https://github.com/masterpointio/github-action-opa-rego-test/compare/v1.1.0...v1.2.0) (2025-04-30) 20 | 21 | 22 | ### Features 23 | 24 | * allow to request static opa binary ([#24](https://github.com/masterpointio/github-action-opa-rego-test/issues/24)) ([1e51290](https://github.com/masterpointio/github-action-opa-rego-test/commit/1e51290cee35301008ad302400f434d8aa37565e)) 25 | 26 | ## [1.1.0](https://github.com/masterpointio/github-action-opa-rego-test/compare/v1.0.0...v1.1.0) (2025-01-15) 27 | 28 | 29 | ### Features 30 | 31 | * **github-action:** add actionlint to lint against gha yamls ([#18](https://github.com/masterpointio/github-action-opa-rego-test/issues/18)) ([7a7b55d](https://github.com/masterpointio/github-action-opa-rego-test/commit/7a7b55dea277136e7c1f2f96d2c5870d2c5282d9)) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * auto trunk upgrade action ([#17](https://github.com/masterpointio/github-action-opa-rego-test/issues/17)) ([491cd45](https://github.com/masterpointio/github-action-opa-rego-test/commit/491cd45379004718df5e7a6ea918998d0707f086)) 37 | * **packagejson:** clean up ([#11](https://github.com/masterpointio/github-action-opa-rego-test/issues/11)) ([d11ba7e](https://github.com/masterpointio/github-action-opa-rego-test/commit/d11ba7e55b93c49396b44b9f8aaf862776e92161)) 38 | 39 | ## 1.0.0 (2024-08-22) 40 | 41 | 42 | ### Features 43 | 44 | * allow different postfix and other inputs and documentation ([#2](https://github.com/masterpointio/github-action-opa-rego-test/issues/2)) ([0ea31f1](https://github.com/masterpointio/github-action-opa-rego-test/commit/0ea31f17b4596799906098c50b0ab5294e8065a0)) 45 | * **codestyle:** linting and trunk ([#6](https://github.com/masterpointio/github-action-opa-rego-test/issues/6)) ([8f4578a](https://github.com/masterpointio/github-action-opa-rego-test/commit/8f4578a3cfa231fa7053402d29e88fb1edd3025b)) 46 | * GHA for testing OPA Rego policies and generating a summary report ([#1](https://github.com/masterpointio/github-action-opa-rego-test/issues/1)) ([8cb09b7](https://github.com/masterpointio/github-action-opa-rego-test/commit/8cb09b77db55a086aaabc33654a09fe6bd2737f2)) 47 | * outputs and release please ([#4](https://github.com/masterpointio/github-action-opa-rego-test/issues/4)) ([1be1316](https://github.com/masterpointio/github-action-opa-rego-test/commit/1be1316628dd30e39648c44f92dd26f33c61b8a4)) 48 | * pin to sha + additional inputs ([#3](https://github.com/masterpointio/github-action-opa-rego-test/issues/3)) ([6236e8b](https://github.com/masterpointio/github-action-opa-rego-test/commit/6236e8b25c8fe69a7c792b9f99d182b758736285)) 49 | * **report-message:** add `indicate_source_message` flag for reference to this repo ([#9](https://github.com/masterpointio/github-action-opa-rego-test/issues/9)) ([e5f6875](https://github.com/masterpointio/github-action-opa-rego-test/commit/e5f6875239ac9aa38c493fca0b7b484e7788f024)) 50 | * **tests:** typescript optimizations and ci tests ([#8](https://github.com/masterpointio/github-action-opa-rego-test/issues/8)) ([d1c2ec7](https://github.com/masterpointio/github-action-opa-rego-test/commit/d1c2ec7568a3ab949ee57aecffb2a011d54c8d6f)) 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Masterpoint Logo](https://masterpoint-public.s3.us-west-2.amazonaws.com/v2/standard-long-fullcolor.png)](https://masterpoint.io) 2 | 3 | # GitHub Action for OPA Rego Policy Tests [![Latest Release](https://img.shields.io/github/release/masterpointio/github-action-opa-rego-test.svg)](https://github.com/masterpointio/github-action-opa-rego-test/releases/latest) 4 | 5 | GitHub Action to automate testing for your OPA (Open Policy Agent) Rego policies, generates a report with coverage information, and posts the test results as a comment on your pull requests, making it easy for your team to review and approve policies. 6 | 7 | Use this to test your OPA Rego files for [Spacelift policies](https://docs.spacelift.io/concepts/policy), Kubernetes Admission Controller policies, Docker authorization policies, or any other use case that uses [Open Policy Agent's policy language Rego](https://www.openpolicyagent.org/docs/latest/). This Action also updates PR comments with the test results in place to prevent duplication. 8 | 9 | OPA Logo 10 | 11 | OPA Rego Test GitHub Comment Example 12 | 13 | See examples of the pull request comments below at the [Example Pull Request Comments section](#-example-pull-request-comments). 14 | 15 | 📚 Table of Contents 16 | 17 | - [🚀 Usage](#-usage) 18 | - [Inputs](#inputs) 19 | - [⚙️ How It Works](#️-how-it-works) 20 | - [🧪 Running Tests](#-running-tests) 21 | - [🏗️ Setup & Run Locally](#️-setup--run-locally) 22 | - [📦 Releases / Packaging for Distribution](#-releases--packaging-for-distribution) 23 | - [🤝 Contributing](#-contributing) 24 | - [💬 Example Pull Request Comments](#-example-pull-request-comments) 25 | 26 | ## 🚀 Usage 27 | 28 | It's super easy to get started and use this GitHub Action to test your OPA Rego policies. In your repository/directory with the `.rego` files and the `_test.rego` files, simply checkout the repository and add the step with `uses: masterpointio/github-action-opa-rego-test@main`. It's as simple as adding the step with no required inputs! It will then generate a PR comment (that updates in place) with the test results! 29 | 30 | ```yaml 31 | - name: Run OPA Rego Tests 32 | uses: masterpointio/github-action-opa-rego-test@main 33 | with: 34 | path: ./examples 35 | test_mode: directory # Whether to test the Rego by directory (e.g. opa test ./) or by individual files (e.g. opa test a_test.rego a.rego). Options of `directory` or `file`. 36 | report_untested_files: true # Flag to check & report Rego files that does NOT have corresponding test files. Optional, defaults to false. 37 | ``` 38 | 39 |
40 | Expand to see full usage example! 41 | 42 | ```yaml 43 | name: Spacelift Policy OPA Rego Tests 44 | 45 | on: 46 | pull_request: 47 | # Optionally only trigger tests on affecting .rego files. 48 | # paths: 49 | # - '**.rego' 50 | 51 | permissions: 52 | id-token: write 53 | contents: read 54 | pull-requests: write # required to comment on PRs 55 | 56 | jobs: 57 | opa-tests: 58 | runs-on: ubuntu-latest 59 | steps: 60 | - name: Check out repository code 61 | uses: actions/checkout@v4 62 | 63 | - name: Run OPA Rego Tests 64 | uses: masterpointio/github-action-opa-rego-test@main 65 | with: 66 | path: "./config/spacelift-policies" # Path of the directory where the OPA Rego policies are stored. 67 | report_untested_files: true # Flag to check & report Rego files without corresponding test files. Optional, defaults to false. 68 | ``` 69 | 70 |
71 | 72 | Be sure to always append the postfix to your test files. The default input for the `test_file_postfix` is `_test`, per [OPA's best practices](https://www.openpolicyagent.org/docs/latest/policy-testing/#test-format). If you have a different postfix for your test files, you can specify it in the inputs. This is how GitHub Action know what test to run on files. 73 | 74 | For example, if you have a file named `my-policy.rego`, you would need a file named `my-policy_test.rego`. It does not matter where the `_test.rego` file is located, just that it is in the root path, meaning that it can be in a subdirectory. 75 | 76 | In the example below, all `_test.rego` files' location are valid and will be executed. 77 | 78 | Masterpoint GitHub Action OPA Test File Structure 79 | 80 | ### Inputs 81 | 82 | | Input | Description | Required | Default | 83 | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------------------- | 84 | | `path` | Path to the directory containing OPA Rego files to test | Yes | REQUIRED | 85 | | `test_mode` | Whether to test the Rego by an entire directory (including entire package, e.g. `opa test ./`) or by individual files (e.g. `opa test a_test.rego a.rego`). Options of `directory` or `file`. | No | `directory` | 86 | | `test_file_postfix` | Postfix of the test files to run (e.g. notification.rego <> notification_test.rego) | No | `_test` | 87 | | `write_pr_comment` | Flag to write a user-friendly PR comment with test results | No | `true` | 88 | | `pr_comment_title` | Title of the PR comment for test results | No | `🧪 OPA Rego Policy Test Results` | 89 | | `pr_comment_mode` | Mode that will be used to update comment. Options of upsert (update in place) or recreate. | No | `upsert` | 90 | | `run_coverage_report` | Flag to run OPA coverage tests and include in PR comment | No | `true` | 91 | | `report_untested_files` | Check & report Rego files without corresponding test files | No | `false` | 92 | | `opa_version` | Version of the OPA CLI to use. | No | `1.4.2` | 93 | | `opa_static` | Whether to use the static binary for OPA installation. use. | No | `false` | 94 | | `indicate_source_message` | Flag to comment the origins watermark (this repository) of the GitHub Action in the PR comment. | No | `true` | 95 | 96 | ### Outputs 97 | 98 | | Output | Description | 99 | | ---------------- | ---------------------------------------------------------------------- | 100 | | `parsed_results` | The parsed results after processing the tests and/or coverage report. | 101 | | `tests_failed` | A `true` or `false` flag indicating if any of the tests failed or not. | 102 | 103 | ## ⚙️ How It Works 104 | 105 | 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: 106 | 107 | 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. 114 | 115 | ![Masterpoint OPA Rego Test Action Diagram](https://lucid.app/publicSegments/view/60bf898e-2640-475f-b130-2a70d317a65d/image.png) 116 | 117 | ## 🧪 Running Tests 118 | 119 | On each pull request, there is a GitHub Actions workflow that runs the tests automatically, along with it testing itself by running the Action on itself against the `/examples` directory and commenting the OPA results on the same PR. To test locally, see below: 120 | 121 | 1. `npm install` 122 | 2. `npm run test` 123 | 124 | NPM Test Results 125 | 126 | ## 🏗️ Setup & Run Locally 127 | 128 | You can use [nektos/act](https://github.com/nektos/act) to simulate and run a GitHub Actions workflow locally. 129 | 130 | To directly test the custom TypeScript action locally, you can: 131 | 132 | 1. `npm run install` 133 | 2. `node ./dist/index.js` 134 | This is assuming you have `npm` and `node` installed already. Note: You will have to manually provide the required inputs since this is directly executing the TypeScript code. 135 | Additionally, if you are using VS Code, you can use the `.vscode/launch.json` (which executes `npx ts-node ./src/index.ts`) to run and attach the debugger. 136 | 137 | ## 📦 Releases / Packaging for Distribution 138 | 139 | This Action executes the source from the `/dist` directory. It is generated using [@vercel/ncc](https://github.com/vercel/ncc) to easily compile the TypeScript module into a single file together with all its dependencies, gcc-style, to package it up for use and distribute. 140 | 141 | To package for distribution, simply run the command which will do the above and generate into the `/dist` directory (see the source in `package.json`): 142 | 143 | ```bash 144 | npm run build 145 | ``` 146 | 147 | To create a new release, merge the pull request created by [Release Please](https://github.com/googleapis/release-please). This will automatically create a new release with the version number and the changes made. 148 | 149 | ## 🤝 Contributing 150 | 151 | Contributions are welcome! Please feel free to submit a Pull Request or open any issues you may have. 152 | 153 | ## 💬 Example Pull Request Comments 154 | 155 | One of the testing steps is running the test workflow against this Action itself. You can see some live examples in the closed PR section, including this [example here](https://github.com/masterpointio/github-action-opa-rego-test/pull/9#issuecomment-2305253112). 156 | 157 | - ![Masterpoint GitHub Actions OPA Rego Test PR Example](./assets/readme-example-1.png) 158 | - Using `report_untested_files` to indicate policies without corresponding tests. 159 | - ![Masterpoint GitHub Actions OPA Rego Test PR Example](./assets/readme-example-2.png) 160 | - ![Masterpoint GitHub Actions OPA Rego Test PR Example](./assets/readme-example-3.png) 161 | -------------------------------------------------------------------------------- /__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ProcessedTestResult, 3 | ProcessedCoverageResult, 4 | } from "../src/interfaces"; 5 | import { 6 | mockProcessedTestResults, 7 | mockProcessedCoverageResults, 8 | } from "./mockResults"; 9 | import { 10 | processTestResults, 11 | processCoverageReport, 12 | } from "../src/testResultProcessing"; 13 | import { formatResults } from "../src/formatResults"; 14 | 15 | import * as path from "path"; 16 | import * as fs from "fs"; 17 | 18 | // Obtained by running `opa test ./spacelift_policies --format=json --v0-compatible` 19 | export const testOutput = fs.readFileSync( 20 | path.join(__dirname, "sample_test_output.txt"), 21 | "utf8", 22 | ); 23 | 24 | export const coverageOutput = fs.readFileSync( 25 | path.join(__dirname, "sample_coverage_output.txt"), 26 | "utf8", 27 | ); 28 | 29 | export const multipleUncoveredLinesOutput = fs.readFileSync( 30 | path.join(__dirname, "sample_coverage_multiple_uncovered_lines.txt"), 31 | "utf8", 32 | ); 33 | 34 | describe("processTestResults", () => { 35 | it("should correctly parse test output", () => { 36 | const result = processTestResults(JSON.parse(testOutput)); 37 | expect(result).toHaveLength(8); 38 | 39 | expect(result[0]).toEqual({ 40 | file: "tests/cancel-in-progress-runs_test.rego", 41 | status: "PASS", 42 | passed: 2, 43 | total: 2, 44 | details: expect.arrayContaining([ 45 | "✅ test_cancel_runs_allowed", 46 | "✅ test_cancel_runs_denied", 47 | ]), 48 | }); 49 | }); 50 | 51 | it("should handle empty input", () => { 52 | const result = processTestResults([]); 53 | expect(result).toEqual([]); 54 | }); 55 | 56 | it("should correctly parse failed tests", () => { 57 | const result = processTestResults(JSON.parse(testOutput)); 58 | expect(result[2]).toEqual({ 59 | file: "tests/enforce-module-use-policy_test.rego", 60 | status: "FAIL", 61 | passed: 3, 62 | total: 4, 63 | details: [ 64 | "✅ test_deny_creation_of_controlled_resource_type", 65 | "✅ test_deny_update_of_controlled_resource_type", 66 | "❌ test_allow_deletion_of_controlled_resource_type", 67 | "✅ test_allow_creation_of_uncontrolled_resource_type", 68 | ], 69 | }); 70 | }); 71 | }); 72 | 73 | describe("processCoverageReport", () => { 74 | const parsedCoverageResults = processCoverageReport( 75 | JSON.parse(coverageOutput), 76 | ); 77 | 78 | it("should correctly parse coverage output - singular not covered lines", () => { 79 | const targetFile = "cancel-in-progress-runs.rego"; 80 | const result = parsedCoverageResults.find( 81 | (item) => item.file === targetFile, 82 | ); 83 | 84 | expect(result).toBeDefined(); 85 | expect(result!.coverage).toBeCloseTo(83.33); 86 | expect(result!.notCoveredLines).toBe("16"); 87 | }); 88 | 89 | it("should correctly parse coverage output - multiple, hyphenated not covered lines", () => { 90 | const targetFile = "enforce-module-use-policy.rego"; 91 | const result = parsedCoverageResults.find( 92 | (item) => item.file === targetFile, 93 | ); 94 | 95 | expect(result).toBeDefined(); 96 | expect(result!.coverage).toBeCloseTo(47.826); 97 | expect(result!.notCoveredLines).toBe( 98 | "37, 42, 46, 52, 54, 57, 60-61, 64, 68, 78, 80", 99 | ); 100 | }); 101 | 102 | it("should correctly parse coverage output - multiple, comma-separated not covered lines", () => { 103 | const targetFile = "readers-writers-admins-teams.rego"; 104 | const result = parsedCoverageResults.find( 105 | (item) => item.file === targetFile, 106 | ); 107 | 108 | expect(result).toBeDefined(); 109 | expect(result!.coverage).toBeCloseTo(83.33); 110 | expect(result!.notCoveredLines).toBe("16, 24, 28"); 111 | }); 112 | 113 | it("should correctly parse coverage output - undefined coverage", () => { 114 | const targetFile = "drift-detection.rego"; 115 | const result = parsedCoverageResults.find( 116 | (item) => item.file === targetFile, 117 | ); 118 | 119 | expect(result).toBeDefined(); 120 | expect(result!.coverage).toBeUndefined(); 121 | expect(result!.notCoveredLines).toBe("3, 5, 8, 11"); 122 | }); 123 | 124 | it("should correctly parse coverage output - 100% coverage with empty not covered lines", () => { 125 | const targetFile = "tests/cancel-in-progress-runs_test.rego"; 126 | const result = parsedCoverageResults.find( 127 | (item) => item.file === targetFile, 128 | ); 129 | 130 | expect(result).toBeDefined(); 131 | expect(result!.coverage).toBe(100); 132 | expect(result!.notCoveredLines).toBe(""); 133 | }); 134 | }); 135 | 136 | describe("formatResults", () => { 137 | const parsedTestResults = mockProcessedTestResults; 138 | const parsedCoverageResults = mockProcessedCoverageResults; 139 | 140 | it("should correctly format results with coverage", () => { 141 | const testResults: ProcessedTestResult[] = [ 142 | { 143 | file: "./examples/tests/ignore-changes-outside-root_test.rego", 144 | status: "PASS", 145 | passed: 12, 146 | total: 12, 147 | details: ["✅ test1", "✅ test2", "✅ test3"], 148 | }, 149 | ]; 150 | const specificCoverageResult = parsedCoverageResults.filter( 151 | (res) => res.file === "tests/ignore-changes-outside-root.rego", 152 | ); 153 | 154 | const result = formatResults(testResults, specificCoverageResult, true); 155 | expect(result).toContain("# 🧪 OPA Rego Policy Test Results"); 156 | expect(result).toContain( 157 | "| File | Status | Passed | Total | Coverage | Details |", 158 | ); 159 | expect(result).toContain( 160 | "| ./examples/tests/ignore-changes-outside-root_test.rego | ✅ PASS | 12 | 12 | 97.44% |
Show Details✅ test1
✅ test2
✅ test3
|", 161 | ); 162 | expect(result).toContain( 163 | "
Show Details✅ test1
✅ test2
✅ test3
", 164 | ); 165 | }); 166 | 167 | it("should correctly format results without coverage and failed", () => { 168 | const testResults: ProcessedTestResult[] = [ 169 | { 170 | file: "./examples/tests/ignore-changes-outside-root_test.rego", 171 | status: "FAIL", 172 | passed: 11, 173 | total: 12, 174 | details: ["✅ test1", "✅ test2", "❌ test3"], 175 | }, 176 | ]; 177 | const result = formatResults(testResults, [], false); 178 | expect(result).toContain("# 🧪 OPA Rego Policy Test Results"); 179 | expect(result).toContain("| File | Status | Passed | Total | Details |"); 180 | expect(result).toContain( 181 | "| ./examples/tests/ignore-changes-outside-root_test.rego | ❌ FAIL | 11 | 12 |
Show Details✅ test1
✅ test2
❌ test3
|", 182 | ); 183 | }); 184 | 185 | it("should handle all test statuses and coverage scenarios from parsed results", () => { 186 | const result = formatResults( 187 | mockProcessedTestResults, 188 | mockProcessedCoverageResults, 189 | true, 190 | ); 191 | 192 | expect(result).toContain( 193 | "| tests/ignore-changes-outside-root_test.rego | ✅ PASS | 12 | 12 | 97.44% |
Show Details✅ test1
✅ test2
✅ test3
✅ test4
✅ test5
✅ test6
✅ test7
✅ test8
✅ test9
✅ test10
✅ test11
✅ test12
|", 194 | ); 195 | 196 | expect(result).toContain( 197 | "| tests/track-using-labels_test.rego | ✅ PASS | 8 | 8 | 76.47% |
Show Details✅ test_label_match
✅ test_label_no_match
✅ test_no_labels_on_resource
✅ test_no_labels_on_constraint
✅ test_empty_labels_on_resource
✅ test_empty_labels_on_constraint
✅ test_multiple_labels_match
✅ test_multiple_labels_no_match
|", 198 | ); 199 | 200 | expect(result).toContain( 201 | "| tests/readers-writers-admins-teams_test.rego | ✅ PASS | 6 | 6 | 83.33%
Uncovered Lines16, 24, 28
|
Show Details✅ test_reader_access
✅ test_writer_access
✅ test_admin_access
✅ test_no_access
✅ test_multiple_roles
✅ test_nested_teams
|", 202 | ); 203 | 204 | expect(result).toContain( 205 | "| tests/cancel-in-progress-runs_test.rego | ✅ PASS | 2 | 2 | 83.33%
Uncovered Lines16
|
Show Details✅ test_cancel_successful
✅ test_cancel_failure
|", 206 | ); 207 | 208 | const fileNames = [ 209 | "ignore-changes-outside-root_test.rego", 210 | "track-using-labels_test.rego", 211 | "enforce-password-length_test.rego", 212 | "notification-stack-failure-origins_test.rego", 213 | "enforce-module-use-policy_test.rego", 214 | "readers-writers-admins-teams_test.rego", 215 | "cancel-in-progress-runs_test.rego", 216 | "do-not-delete-stateful-resources_test.rego", 217 | ]; 218 | 219 | fileNames.forEach((fileName) => { 220 | expect(result).toContain(`tests/${fileName}`); 221 | }); 222 | 223 | const resultRows = result 224 | .split("\n") 225 | .filter((line) => line.startsWith("|") && line.includes("PASS")); 226 | expect(resultRows.length).toBe( 227 | parsedTestResults.filter((r) => r.status === "PASS").length, 228 | ); 229 | }); 230 | 231 | it("should format results without coverage when showCoverage is false", () => { 232 | const result = formatResults( 233 | parsedTestResults, 234 | parsedCoverageResults, 235 | false, 236 | ); 237 | expect(result).not.toContain("Coverage"); 238 | expect(result).toContain("| File | Status | Passed | Total | Details |"); 239 | expect(result).toContain( 240 | "| tests/ignore-changes-outside-root_test.rego | ✅ PASS | 12 | 12 |", 241 | ); 242 | }); 243 | 244 | it("should handle 'NO TESTS' status", () => { 245 | const testResults: ProcessedTestResult[] = [ 246 | { 247 | file: "./examples/no_test_file.rego", 248 | status: "NO TESTS", 249 | passed: 0, 250 | total: 0, 251 | details: [], 252 | }, 253 | ]; 254 | const result = formatResults(testResults, [], false); 255 | expect(result).toContain( 256 | "| ./examples/no_test_file.rego | ⚠️ NO TESTS | 0 | 0 |
Show DetailsNo test file found
|", 257 | ); 258 | }); 259 | 260 | it("should correctly match coverage info with test file", () => { 261 | const testResults: ProcessedTestResult[] = [ 262 | { 263 | file: "./examples/tests/ignore-changes-outside-root_test.rego", 264 | status: "PASS", 265 | passed: 12, 266 | total: 12, 267 | details: ["✅ test1", "✅ test2", "✅ test3"], 268 | }, 269 | ]; 270 | const specificCoverageResult = parsedCoverageResults.filter( 271 | (res) => res.file === "tests/ignore-changes-outside-root.rego", 272 | ); 273 | const result = formatResults(testResults, specificCoverageResult, true); 274 | expect(result).toContain( 275 | "| ./examples/tests/ignore-changes-outside-root_test.rego | ✅ PASS | 12 | 12 | 97.44% |
Show Details✅ test1
✅ test2
✅ test3
|", 276 | ); 277 | }); 278 | 279 | it("should handle cases where coverage info is not found", () => { 280 | const testResults: ProcessedTestResult[] = [ 281 | { 282 | file: "./examples/tests/non-existent-file_test.rego", 283 | status: "PASS", 284 | passed: 1, 285 | total: 1, 286 | details: ["✅ test1"], 287 | }, 288 | ]; 289 | const result = formatResults(testResults, parsedCoverageResults, true); 290 | expect(result).toContain( 291 | "| ./examples/tests/non-existent-file_test.rego | ✅ PASS | 1 | 1 | N/A |
Show Details✅ test1
|", 292 | ); 293 | }); 294 | }); 295 | -------------------------------------------------------------------------------- /__tests__/mockResults.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ProcessedTestResult, 3 | ProcessedCoverageResult, 4 | } from "../src/interfaces"; 5 | 6 | export const mockProcessedTestResults: ProcessedTestResult[] = [ 7 | { 8 | file: "tests/ignore-changes-outside-root_test.rego", 9 | status: "PASS", 10 | passed: 12, 11 | total: 12, 12 | details: [ 13 | "✅ test1", 14 | "✅ test2", 15 | "✅ test3", 16 | "✅ test4", 17 | "✅ test5", 18 | "✅ test6", 19 | "✅ test7", 20 | "✅ test8", 21 | "✅ test9", 22 | "✅ test10", 23 | "✅ test11", 24 | "✅ test12", 25 | ], 26 | }, 27 | { 28 | file: "tests/track-using-labels_test.rego", 29 | status: "PASS", 30 | passed: 8, 31 | total: 8, 32 | details: [ 33 | "✅ test_label_match", 34 | "✅ test_label_no_match", 35 | "✅ test_no_labels_on_resource", 36 | "✅ test_no_labels_on_constraint", 37 | "✅ test_empty_labels_on_resource", 38 | "✅ test_empty_labels_on_constraint", 39 | "✅ test_multiple_labels_match", 40 | "✅ test_multiple_labels_no_match", 41 | ], 42 | }, 43 | { 44 | file: "tests/enforce-password-length_test.rego", 45 | status: "PASS", 46 | passed: 3, 47 | total: 3, 48 | details: [ 49 | "✅ test_password_too_short", 50 | "✅ test_password_just_right", 51 | "✅ test_password_too_long", 52 | ], 53 | }, 54 | { 55 | file: "tests/notification-stack-failure-origins_test.rego", 56 | status: "PASS", 57 | passed: 5, 58 | total: 5, 59 | details: [ 60 | "✅ test_stack_failure_origin_ami", 61 | "✅ test_stack_failure_origin_instance", 62 | "✅ test_stack_failure_origin_security_group", 63 | "✅ test_stack_failure_origin_vpc", 64 | "✅ test_stack_failure_origin_unknown", 65 | ], 66 | }, 67 | { 68 | file: "tests/enforce-module-use-policy_test.rego", 69 | status: "PASS", 70 | passed: 4, 71 | total: 4, 72 | details: [ 73 | "✅ test_valid_module_use", 74 | "✅ test_invalid_module_use", 75 | "✅ test_no_module_imports", 76 | "✅ test_mixed_module_use", 77 | ], 78 | }, 79 | { 80 | file: "tests/readers-writers-admins-teams_test.rego", 81 | status: "PASS", 82 | passed: 6, 83 | total: 6, 84 | details: [ 85 | "✅ test_reader_access", 86 | "✅ test_writer_access", 87 | "✅ test_admin_access", 88 | "✅ test_no_access", 89 | "✅ test_multiple_roles", 90 | "✅ test_nested_teams", 91 | ], 92 | }, 93 | { 94 | file: "tests/cancel-in-progress-runs_test.rego", 95 | status: "PASS", 96 | passed: 2, 97 | total: 2, 98 | details: ["✅ test_cancel_successful", "✅ test_cancel_failure"], 99 | }, 100 | { 101 | file: "tests/do-not-delete-stateful-resources_test.rego", 102 | status: "PASS", 103 | passed: 1, 104 | total: 1, 105 | details: ["✅ test_delete_stateful_resource"], 106 | }, 107 | { 108 | file: "./examples/no_test_file.rego", 109 | status: "NO TESTS", 110 | passed: 0, 111 | total: 0, 112 | details: ["No test file found"], 113 | }, 114 | { 115 | file: "./examples/tests/non-existent-file_test.rego", 116 | status: "PASS", 117 | passed: 1, 118 | total: 1, 119 | details: ["✅ test1"], 120 | }, 121 | ]; 122 | 123 | export const mockProcessedCoverageResults: ProcessedCoverageResult[] = [ 124 | { 125 | file: "tests/ignore-changes-outside-root.rego", 126 | coverage: 97.44, 127 | notCoveredLines: "", 128 | }, 129 | { 130 | file: "tests/track-using-labels.rego", 131 | coverage: 76.47, 132 | notCoveredLines: "", 133 | }, 134 | { 135 | file: "tests/enforce-password-length.rego", 136 | coverage: 100.0, 137 | notCoveredLines: "", 138 | }, 139 | { 140 | file: "tests/notification-stack-failure-origins.rego", 141 | coverage: 90.0, 142 | notCoveredLines: "10, 15", 143 | }, 144 | { 145 | file: "tests/enforce-module-use-policy.rego", 146 | coverage: 85.0, 147 | notCoveredLines: "5", 148 | }, 149 | { 150 | file: "tests/readers-writers-admins-teams.rego", 151 | coverage: 83.33, 152 | notCoveredLines: "16, 24, 28", 153 | }, 154 | { 155 | file: "tests/cancel-in-progress-runs.rego", 156 | coverage: 83.33, 157 | notCoveredLines: "16", 158 | }, 159 | { 160 | file: "tests/do-not-delete-stateful-resources.rego", 161 | coverage: 100.0, 162 | notCoveredLines: "", 163 | }, 164 | ]; 165 | -------------------------------------------------------------------------------- /__tests__/sample_coverage_multiple_uncovered_lines.txt: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "./examples/test.rego": { 4 | "covered": [], 5 | "not_covered": [ 6 | { 7 | "start": { 8 | "row": 10 9 | }, 10 | "end": { 11 | "row": 12 12 | } 13 | }, 14 | { 15 | "start": { 16 | "row": 15 17 | }, 18 | "end": { 19 | "row": 15 20 | } 21 | } 22 | ], 23 | "coverage": 80 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /__tests__/sample_coverage_output.txt: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "cancel-in-progress-runs.rego": { 4 | "covered": [ 5 | { 6 | "start": { 7 | "row": 7 8 | }, 9 | "end": { 10 | "row": 11 11 | } 12 | } 13 | ], 14 | "not_covered": [ 15 | { 16 | "start": { 17 | "row": 16 18 | }, 19 | "end": { 20 | "row": 16 21 | } 22 | } 23 | ], 24 | "covered_lines": 5, 25 | "not_covered_lines": 1, 26 | "coverage": 83.33333333333333 27 | }, 28 | "do-not-delete-stateful-resources.rego": { 29 | "covered": [ 30 | { 31 | "start": { 32 | "row": 10 33 | }, 34 | "end": { 35 | "row": 10 36 | } 37 | }, 38 | { 39 | "start": { 40 | "row": 12 41 | }, 42 | "end": { 43 | "row": 12 44 | } 45 | }, 46 | { 47 | "start": { 48 | "row": 15 49 | }, 50 | "end": { 51 | "row": 15 52 | } 53 | }, 54 | { 55 | "start": { 56 | "row": 18 57 | }, 58 | "end": { 59 | "row": 18 60 | } 61 | }, 62 | { 63 | "start": { 64 | "row": 26 65 | }, 66 | "end": { 67 | "row": 26 68 | } 69 | }, 70 | { 71 | "start": { 72 | "row": 29 73 | }, 74 | "end": { 75 | "row": 29 76 | } 77 | } 78 | ], 79 | "not_covered": [ 80 | { 81 | "start": { 82 | "row": 34 83 | }, 84 | "end": { 85 | "row": 34 86 | } 87 | } 88 | ], 89 | "covered_lines": 6, 90 | "not_covered_lines": 1, 91 | "coverage": 85.71428571428571 92 | }, 93 | "drift-detection.rego": { 94 | "not_covered": [ 95 | { 96 | "start": { 97 | "row": 3 98 | }, 99 | "end": { 100 | "row": 3 101 | } 102 | }, 103 | { 104 | "start": { 105 | "row": 5 106 | }, 107 | "end": { 108 | "row": 5 109 | } 110 | }, 111 | { 112 | "start": { 113 | "row": 8 114 | }, 115 | "end": { 116 | "row": 8 117 | } 118 | }, 119 | { 120 | "start": { 121 | "row": 11 122 | }, 123 | "end": { 124 | "row": 11 125 | } 126 | } 127 | ], 128 | "not_covered_lines": 4 129 | }, 130 | "enforce-module-use-policy.rego": { 131 | "covered": [ 132 | { 133 | "start": { 134 | "row": 17 135 | }, 136 | "end": { 137 | "row": 17 138 | } 139 | }, 140 | { 141 | "start": { 142 | "row": 24 143 | }, 144 | "end": { 145 | "row": 30 146 | } 147 | }, 148 | { 149 | "start": { 150 | "row": 32 151 | }, 152 | "end": { 153 | "row": 32 154 | } 155 | }, 156 | { 157 | "start": { 158 | "row": 39 159 | }, 160 | "end": { 161 | "row": 39 162 | } 163 | }, 164 | { 165 | "start": { 166 | "row": 79 167 | }, 168 | "end": { 169 | "row": 79 170 | } 171 | } 172 | ], 173 | "not_covered": [ 174 | { 175 | "start": { 176 | "row": 37 177 | }, 178 | "end": { 179 | "row": 37 180 | } 181 | }, 182 | { 183 | "start": { 184 | "row": 42 185 | }, 186 | "end": { 187 | "row": 42 188 | } 189 | }, 190 | { 191 | "start": { 192 | "row": 46 193 | }, 194 | "end": { 195 | "row": 46 196 | } 197 | }, 198 | { 199 | "start": { 200 | "row": 52 201 | }, 202 | "end": { 203 | "row": 52 204 | } 205 | }, 206 | { 207 | "start": { 208 | "row": 54 209 | }, 210 | "end": { 211 | "row": 54 212 | } 213 | }, 214 | { 215 | "start": { 216 | "row": 57 217 | }, 218 | "end": { 219 | "row": 57 220 | } 221 | }, 222 | { 223 | "start": { 224 | "row": 60 225 | }, 226 | "end": { 227 | "row": 61 228 | } 229 | }, 230 | { 231 | "start": { 232 | "row": 64 233 | }, 234 | "end": { 235 | "row": 64 236 | } 237 | }, 238 | { 239 | "start": { 240 | "row": 68 241 | }, 242 | "end": { 243 | "row": 68 244 | } 245 | }, 246 | { 247 | "start": { 248 | "row": 78 249 | }, 250 | "end": { 251 | "row": 78 252 | } 253 | }, 254 | { 255 | "start": { 256 | "row": 80 257 | }, 258 | "end": { 259 | "row": 80 260 | } 261 | } 262 | ], 263 | "covered_lines": 11, 264 | "not_covered_lines": 12, 265 | "coverage": 47.82608695652174 266 | }, 267 | "enforce-password-length.rego": { 268 | "covered": [ 269 | { 270 | "start": { 271 | "row": 11 272 | }, 273 | "end": { 274 | "row": 13 275 | } 276 | }, 277 | { 278 | "start": { 279 | "row": 16 280 | }, 281 | "end": { 282 | "row": 18 283 | } 284 | }, 285 | { 286 | "start": { 287 | "row": 21 288 | }, 289 | "end": { 290 | "row": 24 291 | } 292 | } 293 | ], 294 | "not_covered": [ 295 | { 296 | "start": { 297 | "row": 29 298 | }, 299 | "end": { 300 | "row": 29 301 | } 302 | } 303 | ], 304 | "covered_lines": 10, 305 | "not_covered_lines": 1, 306 | "coverage": 90.9090909090909 307 | }, 308 | "ignore-changes-outside-root.rego": { 309 | "covered": [ 310 | { 311 | "start": { 312 | "row": 13 313 | }, 314 | "end": { 315 | "row": 15 316 | } 317 | }, 318 | { 319 | "start": { 320 | "row": 18 321 | }, 322 | "end": { 323 | "row": 19 324 | } 325 | }, 326 | { 327 | "start": { 328 | "row": 22 329 | }, 330 | "end": { 331 | "row": 23 332 | } 333 | }, 334 | { 335 | "start": { 336 | "row": 26 337 | }, 338 | "end": { 339 | "row": 27 340 | } 341 | }, 342 | { 343 | "start": { 344 | "row": 33 345 | }, 346 | "end": { 347 | "row": 34 348 | } 349 | }, 350 | { 351 | "start": { 352 | "row": 36 353 | }, 354 | "end": { 355 | "row": 37 356 | } 357 | } 358 | ], 359 | "not_covered": [ 360 | { 361 | "start": { 362 | "row": 42 363 | }, 364 | "end": { 365 | "row": 42 366 | } 367 | } 368 | ], 369 | "covered_lines": 13, 370 | "not_covered_lines": 1, 371 | "coverage": 92.85714285714286 372 | }, 373 | "notification-stack-failure-origins.rego": { 374 | "covered": [ 375 | { 376 | "start": { 377 | "row": 8 378 | }, 379 | "end": { 380 | "row": 8 381 | } 382 | }, 383 | { 384 | "start": { 385 | "row": 10 386 | }, 387 | "end": { 388 | "row": 10 389 | } 390 | }, 391 | { 392 | "start": { 393 | "row": 15 394 | }, 395 | "end": { 396 | "row": 15 397 | } 398 | }, 399 | { 400 | "start": { 401 | "row": 20 402 | }, 403 | "end": { 404 | "row": 20 405 | } 406 | }, 407 | { 408 | "start": { 409 | "row": 23 410 | }, 411 | "end": { 412 | "row": 25 413 | } 414 | }, 415 | { 416 | "start": { 417 | "row": 28 418 | }, 419 | "end": { 420 | "row": 30 421 | } 422 | }, 423 | { 424 | "start": { 425 | "row": 35 426 | }, 427 | "end": { 428 | "row": 35 429 | } 430 | }, 431 | { 432 | "start": { 433 | "row": 37 434 | }, 435 | "end": { 436 | "row": 38 437 | } 438 | }, 439 | { 440 | "start": { 441 | "row": 45 442 | }, 443 | "end": { 444 | "row": 45 445 | } 446 | }, 447 | { 448 | "start": { 449 | "row": 47 450 | }, 451 | "end": { 452 | "row": 50 453 | } 454 | }, 455 | { 456 | "start": { 457 | "row": 54 458 | }, 459 | "end": { 460 | "row": 55 461 | } 462 | }, 463 | { 464 | "start": { 465 | "row": 60 466 | }, 467 | "end": { 468 | "row": 63 469 | } 470 | }, 471 | { 472 | "start": { 473 | "row": 69 474 | }, 475 | "end": { 476 | "row": 71 477 | } 478 | }, 479 | { 480 | "start": { 481 | "row": 76 482 | }, 483 | "end": { 484 | "row": 77 485 | } 486 | } 487 | ], 488 | "not_covered": [ 489 | { 490 | "start": { 491 | "row": 80 492 | }, 493 | "end": { 494 | "row": 80 495 | } 496 | } 497 | ], 498 | "covered_lines": 29, 499 | "not_covered_lines": 1, 500 | "coverage": 96.66666666666667 501 | }, 502 | "readers-writers-admins-teams.rego": { 503 | "covered": [ 504 | { 505 | "start": { 506 | "row": 8 507 | }, 508 | "end": { 509 | "row": 8 510 | } 511 | }, 512 | { 513 | "start": { 514 | "row": 10 515 | }, 516 | "end": { 517 | "row": 10 518 | } 519 | }, 520 | { 521 | "start": { 522 | "row": 12 523 | }, 524 | "end": { 525 | "row": 12 526 | } 527 | }, 528 | { 529 | "start": { 530 | "row": 17 531 | }, 532 | "end": { 533 | "row": 19 534 | } 535 | }, 536 | { 537 | "start": { 538 | "row": 25 539 | }, 540 | "end": { 541 | "row": 27 542 | } 543 | }, 544 | { 545 | "start": { 546 | "row": 33 547 | }, 548 | "end": { 549 | "row": 38 550 | } 551 | } 552 | ], 553 | "not_covered": [ 554 | { 555 | "start": { 556 | "row": 16 557 | }, 558 | "end": { 559 | "row": 16 560 | } 561 | }, 562 | { 563 | "start": { 564 | "row": 24 565 | }, 566 | "end": { 567 | "row": 24 568 | } 569 | }, 570 | { 571 | "start": { 572 | "row": 28 573 | }, 574 | "end": { 575 | "row": 28 576 | } 577 | } 578 | ], 579 | "covered_lines": 15, 580 | "not_covered_lines": 3, 581 | "coverage": 83.33333333333333 582 | }, 583 | "tests/cancel-in-progress-runs_test.rego": { 584 | "covered": [ 585 | { 586 | "start": { 587 | "row": 3 588 | }, 589 | "end": { 590 | "row": 4 591 | } 592 | }, 593 | { 594 | "start": { 595 | "row": 15 596 | }, 597 | "end": { 598 | "row": 16 599 | } 600 | } 601 | ], 602 | "covered_lines": 4, 603 | "coverage": 100 604 | }, 605 | "tests/do-not-delete-stateful-resources_test.rego": { 606 | "covered": [ 607 | { 608 | "start": { 609 | "row": 4 610 | }, 611 | "end": { 612 | "row": 4 613 | } 614 | }, 615 | { 616 | "start": { 617 | "row": 6 618 | }, 619 | "end": { 620 | "row": 6 621 | } 622 | }, 623 | { 624 | "start": { 625 | "row": 14 626 | }, 627 | "end": { 628 | "row": 14 629 | } 630 | }, 631 | { 632 | "start": { 633 | "row": 16 634 | }, 635 | "end": { 636 | "row": 16 637 | } 638 | }, 639 | { 640 | "start": { 641 | "row": 24 642 | }, 643 | "end": { 644 | "row": 24 645 | } 646 | }, 647 | { 648 | "start": { 649 | "row": 26 650 | }, 651 | "end": { 652 | "row": 26 653 | } 654 | }, 655 | { 656 | "start": { 657 | "row": 34 658 | }, 659 | "end": { 660 | "row": 34 661 | } 662 | }, 663 | { 664 | "start": { 665 | "row": 36 666 | }, 667 | "end": { 668 | "row": 36 669 | } 670 | }, 671 | { 672 | "start": { 673 | "row": 44 674 | }, 675 | "end": { 676 | "row": 44 677 | } 678 | }, 679 | { 680 | "start": { 681 | "row": 46 682 | }, 683 | "end": { 684 | "row": 46 685 | } 686 | } 687 | ], 688 | "covered_lines": 10, 689 | "coverage": 100 690 | }, 691 | "tests/enforce-module-use-policy_test.rego": { 692 | "covered": [ 693 | { 694 | "start": { 695 | "row": 4 696 | }, 697 | "end": { 698 | "row": 5 699 | } 700 | }, 701 | { 702 | "start": { 703 | "row": 13 704 | }, 705 | "end": { 706 | "row": 14 707 | } 708 | }, 709 | { 710 | "start": { 711 | "row": 22 712 | }, 713 | "end": { 714 | "row": 23 715 | } 716 | } 717 | ], 718 | "covered_lines": 6, 719 | "coverage": 100 720 | }, 721 | "tests/enforce-password-length_test.rego": { 722 | "covered": [ 723 | { 724 | "start": { 725 | "row": 4 726 | }, 727 | "end": { 728 | "row": 5 729 | } 730 | }, 731 | { 732 | "start": { 733 | "row": 16 734 | }, 735 | "end": { 736 | "row": 17 737 | } 738 | }, 739 | { 740 | "start": { 741 | "row": 28 742 | }, 743 | "end": { 744 | "row": 29 745 | } 746 | } 747 | ], 748 | "covered_lines": 6, 749 | "coverage": 100 750 | }, 751 | "tests/ignore-changes-outside-root_test.rego": { 752 | "covered": [ 753 | { 754 | "start": { 755 | "row": 3 756 | }, 757 | "end": { 758 | "row": 4 759 | } 760 | }, 761 | { 762 | "start": { 763 | "row": 10 764 | }, 765 | "end": { 766 | "row": 11 767 | } 768 | }, 769 | { 770 | "start": { 771 | "row": 17 772 | }, 773 | "end": { 774 | "row": 18 775 | } 776 | }, 777 | { 778 | "start": { 779 | "row": 24 780 | }, 781 | "end": { 782 | "row": 25 783 | } 784 | }, 785 | { 786 | "start": { 787 | "row": 31 788 | }, 789 | "end": { 790 | "row": 32 791 | } 792 | }, 793 | { 794 | "start": { 795 | "row": 35 796 | }, 797 | "end": { 798 | "row": 36 799 | } 800 | }, 801 | { 802 | "start": { 803 | "row": 39 804 | }, 805 | "end": { 806 | "row": 40 807 | } 808 | }, 809 | { 810 | "start": { 811 | "row": 44 812 | }, 813 | "end": { 814 | "row": 45 815 | } 816 | }, 817 | { 818 | "start": { 819 | "row": 48 820 | }, 821 | "end": { 822 | "row": 49 823 | } 824 | }, 825 | { 826 | "start": { 827 | "row": 52 828 | }, 829 | "end": { 830 | "row": 52 831 | } 832 | }, 833 | { 834 | "start": { 835 | "row": 57 836 | }, 837 | "end": { 838 | "row": 58 839 | } 840 | }, 841 | { 842 | "start": { 843 | "row": 61 844 | }, 845 | "end": { 846 | "row": 62 847 | } 848 | }, 849 | { 850 | "start": { 851 | "row": 65 852 | }, 853 | "end": { 854 | "row": 66 855 | } 856 | } 857 | ], 858 | "covered_lines": 25, 859 | "coverage": 100 860 | }, 861 | "tests/notification-stack-failure-origins_test.rego": { 862 | "covered": [ 863 | { 864 | "start": { 865 | "row": 11 866 | }, 867 | "end": { 868 | "row": 12 869 | } 870 | }, 871 | { 872 | "start": { 873 | "row": 29 874 | }, 875 | "end": { 876 | "row": 34 877 | } 878 | }, 879 | { 880 | "start": { 881 | "row": 38 882 | }, 883 | "end": { 884 | "row": 39 885 | } 886 | }, 887 | { 888 | "start": { 889 | "row": 44 890 | }, 891 | "end": { 892 | "row": 44 893 | } 894 | }, 895 | { 896 | "start": { 897 | "row": 48 898 | }, 899 | "end": { 900 | "row": 49 901 | } 902 | }, 903 | { 904 | "start": { 905 | "row": 54 906 | }, 907 | "end": { 908 | "row": 54 909 | } 910 | }, 911 | { 912 | "start": { 913 | "row": 58 914 | }, 915 | "end": { 916 | "row": 59 917 | } 918 | }, 919 | { 920 | "start": { 921 | "row": 76 922 | }, 923 | "end": { 924 | "row": 78 925 | } 926 | }, 927 | { 928 | "start": { 929 | "row": 82 930 | }, 931 | "end": { 932 | "row": 83 933 | } 934 | }, 935 | { 936 | "start": { 937 | "row": 100 938 | }, 939 | "end": { 940 | "row": 107 941 | } 942 | }, 943 | { 944 | "start": { 945 | "row": 111 946 | }, 947 | "end": { 948 | "row": 112 949 | } 950 | }, 951 | { 952 | "start": { 953 | "row": 117 954 | }, 955 | "end": { 956 | "row": 117 957 | } 958 | }, 959 | { 960 | "start": { 961 | "row": 121 962 | }, 963 | "end": { 964 | "row": 122 965 | } 966 | }, 967 | { 968 | "start": { 969 | "row": 127 970 | }, 971 | "end": { 972 | "row": 127 973 | } 974 | } 975 | ], 976 | "covered_lines": 35, 977 | "coverage": 100 978 | }, 979 | "tests/readers-writers-admins-teams_test.rego": { 980 | "covered": [ 981 | { 982 | "start": { 983 | "row": 9 984 | }, 985 | "end": { 986 | "row": 10 987 | } 988 | }, 989 | { 990 | "start": { 991 | "row": 13 992 | }, 993 | "end": { 994 | "row": 14 995 | } 996 | }, 997 | { 998 | "start": { 999 | "row": 17 1000 | }, 1001 | "end": { 1002 | "row": 18 1003 | } 1004 | }, 1005 | { 1006 | "start": { 1007 | "row": 22 1008 | }, 1009 | "end": { 1010 | "row": 23 1011 | } 1012 | }, 1013 | { 1014 | "start": { 1015 | "row": 27 1016 | }, 1017 | "end": { 1018 | "row": 28 1019 | } 1020 | }, 1021 | { 1022 | "start": { 1023 | "row": 32 1024 | }, 1025 | "end": { 1026 | "row": 33 1027 | } 1028 | } 1029 | ], 1030 | "covered_lines": 12, 1031 | "coverage": 100 1032 | }, 1033 | "tests/track-using-labels_test.rego": { 1034 | "covered": [ 1035 | { 1036 | "start": { 1037 | "row": 4 1038 | }, 1039 | "end": { 1040 | "row": 4 1041 | } 1042 | }, 1043 | { 1044 | "start": { 1045 | "row": 6 1046 | }, 1047 | "end": { 1048 | "row": 6 1049 | } 1050 | }, 1051 | { 1052 | "start": { 1053 | "row": 8 1054 | }, 1055 | "end": { 1056 | "row": 8 1057 | } 1058 | }, 1059 | { 1060 | "start": { 1061 | "row": 13 1062 | }, 1063 | "end": { 1064 | "row": 14 1065 | } 1066 | }, 1067 | { 1068 | "start": { 1069 | "row": 21 1070 | }, 1071 | "end": { 1072 | "row": 22 1073 | } 1074 | }, 1075 | { 1076 | "start": { 1077 | "row": 29 1078 | }, 1079 | "end": { 1080 | "row": 30 1081 | } 1082 | }, 1083 | { 1084 | "start": { 1085 | "row": 37 1086 | }, 1087 | "end": { 1088 | "row": 40 1089 | } 1090 | }, 1091 | { 1092 | "start": { 1093 | "row": 45 1094 | }, 1095 | "end": { 1096 | "row": 48 1097 | } 1098 | }, 1099 | { 1100 | "start": { 1101 | "row": 53 1102 | }, 1103 | "end": { 1104 | "row": 56 1105 | } 1106 | }, 1107 | { 1108 | "start": { 1109 | "row": 61 1110 | }, 1111 | "end": { 1112 | "row": 64 1113 | } 1114 | }, 1115 | { 1116 | "start": { 1117 | "row": 69 1118 | }, 1119 | "end": { 1120 | "row": 72 1121 | } 1122 | } 1123 | ], 1124 | "covered_lines": 29, 1125 | "coverage": 100 1126 | }, 1127 | "track-using-labels.rego": { 1128 | "covered": [ 1129 | { 1130 | "start": { 1131 | "row": 4 1132 | }, 1133 | "end": { 1134 | "row": 5 1135 | } 1136 | }, 1137 | { 1138 | "start": { 1139 | "row": 8 1140 | }, 1141 | "end": { 1142 | "row": 9 1143 | } 1144 | }, 1145 | { 1146 | "start": { 1147 | "row": 13 1148 | }, 1149 | "end": { 1150 | "row": 13 1151 | } 1152 | }, 1153 | { 1154 | "start": { 1155 | "row": 17 1156 | }, 1157 | "end": { 1158 | "row": 20 1159 | } 1160 | }, 1161 | { 1162 | "start": { 1163 | "row": 23 1164 | }, 1165 | "end": { 1166 | "row": 26 1167 | } 1168 | }, 1169 | { 1170 | "start": { 1171 | "row": 29 1172 | }, 1173 | "end": { 1174 | "row": 29 1175 | } 1176 | }, 1177 | { 1178 | "start": { 1179 | "row": 31 1180 | }, 1181 | "end": { 1182 | "row": 32 1183 | } 1184 | }, 1185 | { 1186 | "start": { 1187 | "row": 35 1188 | }, 1189 | "end": { 1190 | "row": 35 1191 | } 1192 | }, 1193 | { 1194 | "start": { 1195 | "row": 37 1196 | }, 1197 | "end": { 1198 | "row": 38 1199 | } 1200 | } 1201 | ], 1202 | "not_covered": [ 1203 | { 1204 | "start": { 1205 | "row": 3 1206 | }, 1207 | "end": { 1208 | "row": 3 1209 | } 1210 | }, 1211 | { 1212 | "start": { 1213 | "row": 12 1214 | }, 1215 | "end": { 1216 | "row": 12 1217 | } 1218 | }, 1219 | { 1220 | "start": { 1221 | "row": 41 1222 | }, 1223 | "end": { 1224 | "row": 41 1225 | } 1226 | } 1227 | ], 1228 | "covered_lines": 19, 1229 | "not_covered_lines": 3, 1230 | "coverage": 86.36363636363636 1231 | } 1232 | }, 1233 | "covered_lines": 235, 1234 | "not_covered_lines": 27, 1235 | "coverage": 89.69465648854961 1236 | } 1237 | -------------------------------------------------------------------------------- /__tests__/sample_test_output.txt: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "location": { 4 | "file": "tests/cancel-in-progress-runs_test.rego", 5 | "row": 3, 6 | "col": 1 7 | }, 8 | "package": "data.spacelift", 9 | "name": "test_cancel_runs_allowed", 10 | "duration": 255417 11 | }, 12 | { 13 | "location": { 14 | "file": "tests/cancel-in-progress-runs_test.rego", 15 | "row": 15, 16 | "col": 1 17 | }, 18 | "package": "data.spacelift", 19 | "name": "test_cancel_runs_denied", 20 | "duration": 162583 21 | }, 22 | { 23 | "location": { 24 | "file": "tests/do-not-delete-stateful-resources_test.rego", 25 | "row": 4, 26 | "col": 1 27 | }, 28 | "package": "data.spacelift", 29 | "name": "test_deny_s3_bucket_deletion", 30 | "duration": 619459 31 | }, 32 | { 33 | "location": { 34 | "file": "tests/do-not-delete-stateful-resources_test.rego", 35 | "row": 14, 36 | "col": 1 37 | }, 38 | "package": "data.spacelift", 39 | "name": "test_deny_db_instance_deletion", 40 | "duration": 163791 41 | }, 42 | { 43 | "location": { 44 | "file": "tests/do-not-delete-stateful-resources_test.rego", 45 | "row": 24, 46 | "col": 1 47 | }, 48 | "package": "data.spacelift", 49 | "name": "test_deny_efs_file_system_deletion", 50 | "duration": 177250 51 | }, 52 | { 53 | "location": { 54 | "file": "tests/do-not-delete-stateful-resources_test.rego", 55 | "row": 34, 56 | "col": 1 57 | }, 58 | "package": "data.spacelift", 59 | "name": "test_deny_dynamodb_table_deletion", 60 | "duration": 152166 61 | }, 62 | { 63 | "location": { 64 | "file": "tests/do-not-delete-stateful-resources_test.rego", 65 | "row": 44, 66 | "col": 1 67 | }, 68 | "package": "data.spacelift", 69 | "name": "test_allow_instance_deletion", 70 | "duration": 138917 71 | }, 72 | { 73 | "location": { 74 | "file": "tests/enforce-module-use-policy_test.rego", 75 | "row": 4, 76 | "col": 1 77 | }, 78 | "package": "data.spacelift", 79 | "name": "test_deny_creation_of_controlled_resource_type", 80 | "duration": 215291 81 | }, 82 | { 83 | "location": { 84 | "file": "tests/enforce-module-use-policy_test.rego", 85 | "row": 13, 86 | "col": 1 87 | }, 88 | "package": "data.spacelift", 89 | "name": "test_deny_update_of_controlled_resource_type", 90 | "duration": 208042 91 | }, 92 | { 93 | "location": { 94 | "file": "tests/enforce-module-use-policy_test.rego", 95 | "row": 22, 96 | "col": 1 97 | }, 98 | "package": "data.spacelift", 99 | "name": "test_allow_deletion_of_controlled_resource_type", 100 | "fail": true, 101 | "duration": 147958 102 | }, 103 | { 104 | "location": { 105 | "file": "tests/enforce-module-use-policy_test.rego", 106 | "row": 31, 107 | "col": 1 108 | }, 109 | "package": "data.spacelift", 110 | "name": "test_allow_creation_of_uncontrolled_resource_type", 111 | "duration": 131542 112 | }, 113 | { 114 | "location": { 115 | "file": "tests/enforce-password-length_test.rego", 116 | "row": 4, 117 | "col": 1 118 | }, 119 | "package": "data.spacelift", 120 | "name": "test_deny_creation_of_password_with_less_than_16_characters", 121 | "duration": 172208 122 | }, 123 | { 124 | "location": { 125 | "file": "tests/enforce-password-length_test.rego", 126 | "row": 16, 127 | "col": 1 128 | }, 129 | "package": "data.spacelift", 130 | "name": "test_warn_creation_of_password_between_16_and_20_characters", 131 | "duration": 89042 132 | }, 133 | { 134 | "location": { 135 | "file": "tests/enforce-password-length_test.rego", 136 | "row": 28, 137 | "col": 1 138 | }, 139 | "package": "data.spacelift", 140 | "name": "test_allow_creation_of_password_longer_than_20_characters", 141 | "duration": 86959 142 | }, 143 | { 144 | "location": { 145 | "file": "tests/ignore-changes-outside-root_test.rego", 146 | "row": 3, 147 | "col": 1 148 | }, 149 | "package": "data.spacelift", 150 | "name": "test_affected_no_files", 151 | "duration": 51958 152 | }, 153 | { 154 | "location": { 155 | "file": "tests/ignore-changes-outside-root_test.rego", 156 | "row": 10, 157 | "col": 1 158 | }, 159 | "package": "data.spacelift", 160 | "name": "test_affected_tf_files", 161 | "duration": 103042 162 | }, 163 | { 164 | "location": { 165 | "file": "tests/ignore-changes-outside-root_test.rego", 166 | "row": 17, 167 | "col": 1 168 | }, 169 | "package": "data.spacelift", 170 | "name": "test_affected_no_tf_files", 171 | "duration": 392583 172 | }, 173 | { 174 | "location": { 175 | "file": "tests/ignore-changes-outside-root_test.rego", 176 | "row": 24, 177 | "col": 1 178 | }, 179 | "package": "data.spacelift", 180 | "name": "test_affected_outside_project_root", 181 | "duration": 272209 182 | }, 183 | { 184 | "location": { 185 | "file": "tests/ignore-changes-outside-root_test.rego", 186 | "row": 31, 187 | "col": 1 188 | }, 189 | "package": "data.spacelift", 190 | "name": "test_ignore_affected", 191 | "duration": 48208 192 | }, 193 | { 194 | "location": { 195 | "file": "tests/ignore-changes-outside-root_test.rego", 196 | "row": 35, 197 | "col": 1 198 | }, 199 | "package": "data.spacelift", 200 | "name": "test_ignore_not_affected", 201 | "duration": 49334 202 | }, 203 | { 204 | "location": { 205 | "file": "tests/ignore-changes-outside-root_test.rego", 206 | "row": 39, 207 | "col": 1 208 | }, 209 | "package": "data.spacelift", 210 | "name": "test_ignore_tag", 211 | "duration": 61291 212 | }, 213 | { 214 | "location": { 215 | "file": "tests/ignore-changes-outside-root_test.rego", 216 | "row": 44, 217 | "col": 1 218 | }, 219 | "package": "data.spacelift", 220 | "name": "test_propose_affected", 221 | "duration": 51625 222 | }, 223 | { 224 | "location": { 225 | "file": "tests/ignore-changes-outside-root_test.rego", 226 | "row": 48, 227 | "col": 1 228 | }, 229 | "package": "data.spacelift", 230 | "name": "test_propose_not_affected", 231 | "duration": 41833 232 | }, 233 | { 234 | "location": { 235 | "file": "tests/ignore-changes-outside-root_test.rego", 236 | "row": 57, 237 | "col": 1 238 | }, 239 | "package": "data.spacelift", 240 | "name": "test_track_affected", 241 | "duration": 60291 242 | }, 243 | { 244 | "location": { 245 | "file": "tests/ignore-changes-outside-root_test.rego", 246 | "row": 61, 247 | "col": 1 248 | }, 249 | "package": "data.spacelift", 250 | "name": "test_track_not_affected", 251 | "duration": 59042 252 | }, 253 | { 254 | "location": { 255 | "file": "tests/ignore-changes-outside-root_test.rego", 256 | "row": 65, 257 | "col": 1 258 | }, 259 | "package": "data.spacelift", 260 | "name": "test_track_not_stack_branch", 261 | "duration": 52666 262 | }, 263 | { 264 | "location": { 265 | "file": "tests/notification-stack-failure-origins_test.rego", 266 | "row": 11, 267 | "col": 1 268 | }, 269 | "package": "data.spacelift", 270 | "name": "test_slack_notification_for_tracked_failed_run", 271 | "duration": 283000 272 | }, 273 | { 274 | "location": { 275 | "file": "tests/notification-stack-failure-origins_test.rego", 276 | "row": 38, 277 | "col": 1 278 | }, 279 | "package": "data.spacelift", 280 | "name": "test_no_slack_notification_for_non_tracked_run", 281 | "duration": 47916 282 | }, 283 | { 284 | "location": { 285 | "file": "tests/notification-stack-failure-origins_test.rego", 286 | "row": 48, 287 | "col": 1 288 | }, 289 | "package": "data.spacelift", 290 | "name": "test_no_slack_notification_for_successful_run", 291 | "duration": 48083 292 | }, 293 | { 294 | "location": { 295 | "file": "tests/notification-stack-failure-origins_test.rego", 296 | "row": 58, 297 | "col": 1 298 | }, 299 | "package": "data.spacelift", 300 | "name": "test_slack_notification_with_unknown_github_user", 301 | "duration": 185666 302 | }, 303 | { 304 | "location": { 305 | "file": "tests/notification-stack-failure-origins_test.rego", 306 | "row": 82, 307 | "col": 1 308 | }, 309 | "package": "data.spacelift", 310 | "name": "test_pr_comment_for_tracked_failed_run", 311 | "duration": 189083 312 | }, 313 | { 314 | "location": { 315 | "file": "tests/notification-stack-failure-origins_test.rego", 316 | "row": 111, 317 | "col": 1 318 | }, 319 | "package": "data.spacelift", 320 | "name": "test_no_pr_comment_for_non_tracked_run", 321 | "duration": 45875 322 | }, 323 | { 324 | "location": { 325 | "file": "tests/notification-stack-failure-origins_test.rego", 326 | "row": 121, 327 | "col": 1 328 | }, 329 | "package": "data.spacelift", 330 | "name": "test_no_pr_comment_for_successful_run", 331 | "duration": 47250 332 | }, 333 | { 334 | "location": { 335 | "file": "tests/readers-writers-admins-teams_test.rego", 336 | "row": 9, 337 | "col": 1 338 | }, 339 | "package": "data.spacelift_test", 340 | "name": "test_allow_writers", 341 | "duration": 45292 342 | }, 343 | { 344 | "location": { 345 | "file": "tests/readers-writers-admins-teams_test.rego", 346 | "row": 13, 347 | "col": 1 348 | }, 349 | "package": "data.spacelift_test", 350 | "name": "test_allow_admins", 351 | "duration": 44667 352 | }, 353 | { 354 | "location": { 355 | "file": "tests/readers-writers-admins-teams_test.rego", 356 | "row": 17, 357 | "col": 1 358 | }, 359 | "package": "data.spacelift_test", 360 | "name": "test_allow_readers", 361 | "duration": 45291 362 | }, 363 | { 364 | "location": { 365 | "file": "tests/readers-writers-admins-teams_test.rego", 366 | "row": 22, 367 | "col": 1 368 | }, 369 | "package": "data.spacelift_test", 370 | "name": "test_space_admin_access", 371 | "duration": 58625 372 | }, 373 | { 374 | "location": { 375 | "file": "tests/readers-writers-admins-teams_test.rego", 376 | "row": 27, 377 | "col": 1 378 | }, 379 | "package": "data.spacelift_test", 380 | "name": "test_space_write_access", 381 | "duration": 58667 382 | }, 383 | { 384 | "location": { 385 | "file": "tests/readers-writers-admins-teams_test.rego", 386 | "row": 32, 387 | "col": 1 388 | }, 389 | "package": "data.spacelift_test", 390 | "name": "test_space_read_access", 391 | "duration": 85959 392 | }, 393 | { 394 | "location": { 395 | "file": "tests/track-using-labels_test.rego", 396 | "row": 13, 397 | "col": 1 398 | }, 399 | "package": "data.spacelift", 400 | "name": "test_track_different_branches", 401 | "duration": 51834 402 | }, 403 | { 404 | "location": { 405 | "file": "tests/track-using-labels_test.rego", 406 | "row": 21, 407 | "col": 1 408 | }, 409 | "package": "data.spacelift", 410 | "name": "test_propose_non_empty_branch", 411 | "duration": 53416 412 | }, 413 | { 414 | "location": { 415 | "file": "tests/track-using-labels_test.rego", 416 | "row": 29, 417 | "col": 1 418 | }, 419 | "package": "data.spacelift", 420 | "name": "test_propose_empty_branch", 421 | "duration": 50166 422 | }, 423 | { 424 | "location": { 425 | "file": "tests/track-using-labels_test.rego", 426 | "row": 37, 427 | "col": 1 428 | }, 429 | "package": "data.spacelift", 430 | "name": "test_affected_directory", 431 | "duration": 474417 432 | }, 433 | { 434 | "location": { 435 | "file": "tests/track-using-labels_test.rego", 436 | "row": 45, 437 | "col": 1 438 | }, 439 | "package": "data.spacelift", 440 | "name": "test_affected_extension", 441 | "duration": 176334 442 | }, 443 | { 444 | "location": { 445 | "file": "tests/track-using-labels_test.rego", 446 | "row": 53, 447 | "col": 1 448 | }, 449 | "package": "data.spacelift", 450 | "name": "test_not_affected_directory", 451 | "duration": 143458 452 | }, 453 | { 454 | "location": { 455 | "file": "tests/track-using-labels_test.rego", 456 | "row": 61, 457 | "col": 1 458 | }, 459 | "package": "data.spacelift", 460 | "name": "test_not_affected_extension", 461 | "duration": 140709 462 | }, 463 | { 464 | "location": { 465 | "file": "tests/track-using-labels_test.rego", 466 | "row": 69, 467 | "col": 1 468 | }, 469 | "package": "data.spacelift", 470 | "name": "test_ignore_not_affected#01", 471 | "duration": 145166 472 | } 473 | ] 474 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "OPA Rego Test and Coverage Report" 2 | description: "Run OPA tests and generate coverage report for PRs. Test your OPA Rego policies!" 3 | author: Masterpoint 4 | 5 | branding: 6 | icon: "zap" 7 | color: "blue" 8 | 9 | inputs: 10 | path: 11 | description: "Path to the directory containing OPA Rego files to test." 12 | required: true 13 | test_mode: 14 | description: Whether to test the Rego by an entire directory (including entire package, e.g. `opa test ./`) or by individual files (e.g. `opa test a_test.rego a.rego`). Options of `directory` or `file`. Default is `directory`. 15 | required: false 16 | default: directory 17 | test_file_postfix: 18 | description: 'The postfix to use for test files, only applicable for testing file by file. E.g. notification.rego <> notification_test.rego. Default is "_test".' 19 | required: false 20 | default: "_test" 21 | write_pr_comment: 22 | description: "Flag to write an user friendly PR comment of the test results. Default of true." 23 | required: false 24 | default: "true" 25 | pr_comment_title: 26 | description: "Title of the PR comment of the test results." 27 | required: false 28 | default: "🧪 OPA Rego Policy Test Results" 29 | pr_comment_mode: 30 | description: Mode that will be used to update comment. Options of upsert (update in place) or recreate. 31 | default: "upsert" 32 | run_coverage_report: 33 | description: "Flag to run OPA coverage tests and write to the PR. The `write_pr_comment` must be enabled for the coverage report to be written. Default of true." 34 | required: false 35 | default: "true" 36 | report_untested_files: 37 | description: "Check & report in the PR comments of the Rego files that do not have any corresponding test files. For best conventions, append the postfix `_test` (or what you set as the `test_file_postfix` input) for your test file. E.g. `notification.rego` <> `notification_test.rego`" 38 | required: false 39 | default: "false" 40 | opa_version: 41 | description: "Version of OPA CLI to use. Default is 1.4.2, latest as of 2025-05-15." 42 | required: false 43 | default: "1.4.2" 44 | opa_static: 45 | description: "Whether to use the static binary. Default is false." 46 | required: false 47 | default: "false" 48 | indicate_source_message: 49 | description: Flag to comment the origins (this repository) of the GitHub Action in the PR comment. Default of true. 50 | required: false 51 | default: "true" 52 | outputs: 53 | parsed_results: 54 | description: The parsed results after processing the tests and/or coverage report. 55 | value: ${{ steps.parse-results.outputs.parsed_results }} 56 | tests_failed: 57 | description: A `true` or `false` flag indicating if any of the tests failed or not. 58 | value: ${{ steps.parse-results.outputs.tests_failed }} 59 | 60 | runs: 61 | using: "composite" 62 | steps: 63 | - name: Setup OPA 64 | uses: open-policy-agent/setup-opa@v2 65 | with: 66 | version: ${{ inputs.opa_version }} 67 | static: ${{ inputs.opa_static }} 68 | 69 | - name: Find Rego files without tests 70 | if: inputs.report_untested_files == 'true' 71 | id: find-no-test 72 | shell: bash 73 | run: | 74 | main_dir="${{ inputs.path }}" 75 | echo "Searching for untested Rego files in: $main_dir" 76 | 77 | no_test_files=$(find "$main_dir" -type f -name "*.rego" ! -name "*${{ inputs.test_file_postfix }}.rego" | while read file; do 78 | base_name=$(basename "$file" .rego) 79 | 80 | # Search for a corresponding test file anywhere in the project 81 | test_file=$(find "$main_dir" -type f -name "${base_name}${{ inputs.test_file_postfix }}.rego") 82 | 83 | if [ -z "$test_file" ]; then 84 | echo "$file" 85 | fi 86 | done) 87 | 88 | echo "no_test_files<> $GITHUB_OUTPUT 89 | echo "$no_test_files" >> $GITHUB_OUTPUT 90 | echo "EOF" >> $GITHUB_OUTPUT 91 | 92 | echo "Search complete, found the following Rego files without tests: $no_test_files" 93 | 94 | # Parse and format the test results which will be consumed by the following step to comment on the PR. 95 | - name: "Execute and Parse Results from Tests" 96 | id: parse-results 97 | run: node ${{ github.action_path }}/dist/index.js 98 | shell: bash 99 | env: 100 | test_mode: ${{ inputs.test_mode }} 101 | report_untested_files: ${{ inputs.report_untested_files }} 102 | no_test_files: ${{ steps.find-no-test.outputs.no_test_files }} 103 | pr_comment_title: ${{ inputs.pr_comment_title }} 104 | run_coverage_report: ${{ inputs.run_coverage_report }} 105 | indicate_source_message: ${{ inputs.indicate_source_message }} 106 | path: ${{ inputs.path }} 107 | test_file_postfix: ${{ inputs.test_file_postfix }} 108 | 109 | # Create (or update in-place) a PR comment of the test result output 110 | - name: Comment on PR 111 | uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 112 | # If `write_pr_comment` enabled, regardless of if test is success or fail, write the results of the failure. 113 | # Even if input is bool, it has to be treated as string bc of GH's behavior (https://github.com/actions/runner/issues/1483) 114 | if: inputs.write_pr_comment == 'true' && (success() || failure()) 115 | with: 116 | message: | 117 | ${{ steps.parse-results.outputs.parsed_results }} 118 | comment-tag: opa-test-results-${{ github.action }} # using the action name as the tag as it is unique, and in case multiple executions of the action are in the same PR 119 | mode: ${{ inputs.pr_comment_mode }} 120 | -------------------------------------------------------------------------------- /assets/banner-pr-comment-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masterpointio/github-action-opa-rego-test/7a055ee4efd9f5f536230156adda071c1944d421/assets/banner-pr-comment-example.png -------------------------------------------------------------------------------- /assets/opa-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masterpointio/github-action-opa-rego-test/7a055ee4efd9f5f536230156adda071c1944d421/assets/opa-logo.png -------------------------------------------------------------------------------- /assets/readme-example-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masterpointio/github-action-opa-rego-test/7a055ee4efd9f5f536230156adda071c1944d421/assets/readme-example-1.png -------------------------------------------------------------------------------- /assets/readme-example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masterpointio/github-action-opa-rego-test/7a055ee4efd9f5f536230156adda071c1944d421/assets/readme-example-2.png -------------------------------------------------------------------------------- /assets/readme-example-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masterpointio/github-action-opa-rego-test/7a055ee4efd9f5f536230156adda071c1944d421/assets/readme-example-3.png -------------------------------------------------------------------------------- /assets/readme-test-results.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masterpointio/github-action-opa-rego-test/7a055ee4efd9f5f536230156adda071c1944d421/assets/readme-test-results.png -------------------------------------------------------------------------------- /assets/test-file-structure-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masterpointio/github-action-opa-rego-test/7a055ee4efd9f5f536230156adda071c1944d421/assets/test-file-structure-example.png -------------------------------------------------------------------------------- /dist/sourcemap-register.js: -------------------------------------------------------------------------------- 1 | (()=>{var e={650:e=>{var r=Object.prototype.toString;var n=typeof Buffer.alloc==="function"&&typeof Buffer.allocUnsafe==="function"&&typeof Buffer.from==="function";function isArrayBuffer(e){return r.call(e).slice(8,-1)==="ArrayBuffer"}function fromArrayBuffer(e,r,t){r>>>=0;var o=e.byteLength-r;if(o<0){throw new RangeError("'offset' is out of bounds")}if(t===undefined){t=o}else{t>>>=0;if(t>o){throw new RangeError("'length' is out of bounds")}}return n?Buffer.from(e.slice(r,r+t)):new Buffer(new Uint8Array(e.slice(r,r+t)))}function fromString(e,r){if(typeof r!=="string"||r===""){r="utf8"}if(!Buffer.isEncoding(r)){throw new TypeError('"encoding" must be a valid string encoding')}return n?Buffer.from(e,r):new Buffer(e,r)}function bufferFrom(e,r,t){if(typeof e==="number"){throw new TypeError('"value" argument must not be a number')}if(isArrayBuffer(e)){return fromArrayBuffer(e,r,t)}if(typeof e==="string"){return fromString(e,r)}return n?Buffer.from(e):new Buffer(e)}e.exports=bufferFrom},274:(e,r,n)=>{var t=n(339);var o=Object.prototype.hasOwnProperty;var i=typeof Map!=="undefined";function ArraySet(){this._array=[];this._set=i?new Map:Object.create(null)}ArraySet.fromArray=function ArraySet_fromArray(e,r){var n=new ArraySet;for(var t=0,o=e.length;t=0){return r}}else{var n=t.toSetString(e);if(o.call(this._set,n)){return this._set[n]}}throw new Error('"'+e+'" is not in the set.')};ArraySet.prototype.at=function ArraySet_at(e){if(e>=0&&e{var t=n(190);var o=5;var i=1<>1;return r?-n:n}r.encode=function base64VLQ_encode(e){var r="";var n;var i=toVLQSigned(e);do{n=i&a;i>>>=o;if(i>0){n|=u}r+=t.encode(n)}while(i>0);return r};r.decode=function base64VLQ_decode(e,r,n){var i=e.length;var s=0;var l=0;var c,p;do{if(r>=i){throw new Error("Expected more digits in base 64 VLQ value.")}p=t.decode(e.charCodeAt(r++));if(p===-1){throw new Error("Invalid base64 digit: "+e.charAt(r-1))}c=!!(p&u);p&=a;s=s+(p<{var n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("");r.encode=function(e){if(0<=e&&e{r.GREATEST_LOWER_BOUND=1;r.LEAST_UPPER_BOUND=2;function recursiveSearch(e,n,t,o,i,a){var u=Math.floor((n-e)/2)+e;var s=i(t,o[u],true);if(s===0){return u}else if(s>0){if(n-u>1){return recursiveSearch(u,n,t,o,i,a)}if(a==r.LEAST_UPPER_BOUND){return n1){return recursiveSearch(e,u,t,o,i,a)}if(a==r.LEAST_UPPER_BOUND){return u}else{return e<0?-1:e}}}r.search=function search(e,n,t,o){if(n.length===0){return-1}var i=recursiveSearch(-1,n.length,e,n,t,o||r.GREATEST_LOWER_BOUND);if(i<0){return-1}while(i-1>=0){if(t(n[i],n[i-1],true)!==0){break}--i}return i}},680:(e,r,n)=>{var t=n(339);function generatedPositionAfter(e,r){var n=e.generatedLine;var o=r.generatedLine;var i=e.generatedColumn;var a=r.generatedColumn;return o>n||o==n&&a>=i||t.compareByGeneratedPositionsInflated(e,r)<=0}function MappingList(){this._array=[];this._sorted=true;this._last={generatedLine:-1,generatedColumn:0}}MappingList.prototype.unsortedForEach=function MappingList_forEach(e,r){this._array.forEach(e,r)};MappingList.prototype.add=function MappingList_add(e){if(generatedPositionAfter(this._last,e)){this._last=e;this._array.push(e)}else{this._sorted=false;this._array.push(e)}};MappingList.prototype.toArray=function MappingList_toArray(){if(!this._sorted){this._array.sort(t.compareByGeneratedPositionsInflated);this._sorted=true}return this._array};r.H=MappingList},758:(e,r)=>{function swap(e,r,n){var t=e[r];e[r]=e[n];e[n]=t}function randomIntInRange(e,r){return Math.round(e+Math.random()*(r-e))}function doQuickSort(e,r,n,t){if(n{var t;var o=n(339);var i=n(345);var a=n(274).I;var u=n(449);var s=n(758).U;function SourceMapConsumer(e,r){var n=e;if(typeof e==="string"){n=o.parseSourceMapInput(e)}return n.sections!=null?new IndexedSourceMapConsumer(n,r):new BasicSourceMapConsumer(n,r)}SourceMapConsumer.fromSourceMap=function(e,r){return BasicSourceMapConsumer.fromSourceMap(e,r)};SourceMapConsumer.prototype._version=3;SourceMapConsumer.prototype.__generatedMappings=null;Object.defineProperty(SourceMapConsumer.prototype,"_generatedMappings",{configurable:true,enumerable:true,get:function(){if(!this.__generatedMappings){this._parseMappings(this._mappings,this.sourceRoot)}return this.__generatedMappings}});SourceMapConsumer.prototype.__originalMappings=null;Object.defineProperty(SourceMapConsumer.prototype,"_originalMappings",{configurable:true,enumerable:true,get:function(){if(!this.__originalMappings){this._parseMappings(this._mappings,this.sourceRoot)}return this.__originalMappings}});SourceMapConsumer.prototype._charIsMappingSeparator=function SourceMapConsumer_charIsMappingSeparator(e,r){var n=e.charAt(r);return n===";"||n===","};SourceMapConsumer.prototype._parseMappings=function SourceMapConsumer_parseMappings(e,r){throw new Error("Subclasses must implement _parseMappings")};SourceMapConsumer.GENERATED_ORDER=1;SourceMapConsumer.ORIGINAL_ORDER=2;SourceMapConsumer.GREATEST_LOWER_BOUND=1;SourceMapConsumer.LEAST_UPPER_BOUND=2;SourceMapConsumer.prototype.eachMapping=function SourceMapConsumer_eachMapping(e,r,n){var t=r||null;var i=n||SourceMapConsumer.GENERATED_ORDER;var a;switch(i){case SourceMapConsumer.GENERATED_ORDER:a=this._generatedMappings;break;case SourceMapConsumer.ORIGINAL_ORDER:a=this._originalMappings;break;default:throw new Error("Unknown order of iteration.")}var u=this.sourceRoot;a.map((function(e){var r=e.source===null?null:this._sources.at(e.source);r=o.computeSourceURL(u,r,this._sourceMapURL);return{source:r,generatedLine:e.generatedLine,generatedColumn:e.generatedColumn,originalLine:e.originalLine,originalColumn:e.originalColumn,name:e.name===null?null:this._names.at(e.name)}}),this).forEach(e,t)};SourceMapConsumer.prototype.allGeneratedPositionsFor=function SourceMapConsumer_allGeneratedPositionsFor(e){var r=o.getArg(e,"line");var n={source:o.getArg(e,"source"),originalLine:r,originalColumn:o.getArg(e,"column",0)};n.source=this._findSourceIndex(n.source);if(n.source<0){return[]}var t=[];var a=this._findMapping(n,this._originalMappings,"originalLine","originalColumn",o.compareByOriginalPositions,i.LEAST_UPPER_BOUND);if(a>=0){var u=this._originalMappings[a];if(e.column===undefined){var s=u.originalLine;while(u&&u.originalLine===s){t.push({line:o.getArg(u,"generatedLine",null),column:o.getArg(u,"generatedColumn",null),lastColumn:o.getArg(u,"lastGeneratedColumn",null)});u=this._originalMappings[++a]}}else{var l=u.originalColumn;while(u&&u.originalLine===r&&u.originalColumn==l){t.push({line:o.getArg(u,"generatedLine",null),column:o.getArg(u,"generatedColumn",null),lastColumn:o.getArg(u,"lastGeneratedColumn",null)});u=this._originalMappings[++a]}}}return t};r.SourceMapConsumer=SourceMapConsumer;function BasicSourceMapConsumer(e,r){var n=e;if(typeof e==="string"){n=o.parseSourceMapInput(e)}var t=o.getArg(n,"version");var i=o.getArg(n,"sources");var u=o.getArg(n,"names",[]);var s=o.getArg(n,"sourceRoot",null);var l=o.getArg(n,"sourcesContent",null);var c=o.getArg(n,"mappings");var p=o.getArg(n,"file",null);if(t!=this._version){throw new Error("Unsupported version: "+t)}if(s){s=o.normalize(s)}i=i.map(String).map(o.normalize).map((function(e){return s&&o.isAbsolute(s)&&o.isAbsolute(e)?o.relative(s,e):e}));this._names=a.fromArray(u.map(String),true);this._sources=a.fromArray(i,true);this._absoluteSources=this._sources.toArray().map((function(e){return o.computeSourceURL(s,e,r)}));this.sourceRoot=s;this.sourcesContent=l;this._mappings=c;this._sourceMapURL=r;this.file=p}BasicSourceMapConsumer.prototype=Object.create(SourceMapConsumer.prototype);BasicSourceMapConsumer.prototype.consumer=SourceMapConsumer;BasicSourceMapConsumer.prototype._findSourceIndex=function(e){var r=e;if(this.sourceRoot!=null){r=o.relative(this.sourceRoot,r)}if(this._sources.has(r)){return this._sources.indexOf(r)}var n;for(n=0;n1){v.source=l+_[1];l+=_[1];v.originalLine=i+_[2];i=v.originalLine;v.originalLine+=1;v.originalColumn=a+_[3];a=v.originalColumn;if(_.length>4){v.name=c+_[4];c+=_[4]}}m.push(v);if(typeof v.originalLine==="number"){d.push(v)}}}s(m,o.compareByGeneratedPositionsDeflated);this.__generatedMappings=m;s(d,o.compareByOriginalPositions);this.__originalMappings=d};BasicSourceMapConsumer.prototype._findMapping=function SourceMapConsumer_findMapping(e,r,n,t,o,a){if(e[n]<=0){throw new TypeError("Line must be greater than or equal to 1, got "+e[n])}if(e[t]<0){throw new TypeError("Column must be greater than or equal to 0, got "+e[t])}return i.search(e,r,o,a)};BasicSourceMapConsumer.prototype.computeColumnSpans=function SourceMapConsumer_computeColumnSpans(){for(var e=0;e=0){var t=this._generatedMappings[n];if(t.generatedLine===r.generatedLine){var i=o.getArg(t,"source",null);if(i!==null){i=this._sources.at(i);i=o.computeSourceURL(this.sourceRoot,i,this._sourceMapURL)}var a=o.getArg(t,"name",null);if(a!==null){a=this._names.at(a)}return{source:i,line:o.getArg(t,"originalLine",null),column:o.getArg(t,"originalColumn",null),name:a}}}return{source:null,line:null,column:null,name:null}};BasicSourceMapConsumer.prototype.hasContentsOfAllSources=function BasicSourceMapConsumer_hasContentsOfAllSources(){if(!this.sourcesContent){return false}return this.sourcesContent.length>=this._sources.size()&&!this.sourcesContent.some((function(e){return e==null}))};BasicSourceMapConsumer.prototype.sourceContentFor=function SourceMapConsumer_sourceContentFor(e,r){if(!this.sourcesContent){return null}var n=this._findSourceIndex(e);if(n>=0){return this.sourcesContent[n]}var t=e;if(this.sourceRoot!=null){t=o.relative(this.sourceRoot,t)}var i;if(this.sourceRoot!=null&&(i=o.urlParse(this.sourceRoot))){var a=t.replace(/^file:\/\//,"");if(i.scheme=="file"&&this._sources.has(a)){return this.sourcesContent[this._sources.indexOf(a)]}if((!i.path||i.path=="/")&&this._sources.has("/"+t)){return this.sourcesContent[this._sources.indexOf("/"+t)]}}if(r){return null}else{throw new Error('"'+t+'" is not in the SourceMap.')}};BasicSourceMapConsumer.prototype.generatedPositionFor=function SourceMapConsumer_generatedPositionFor(e){var r=o.getArg(e,"source");r=this._findSourceIndex(r);if(r<0){return{line:null,column:null,lastColumn:null}}var n={source:r,originalLine:o.getArg(e,"line"),originalColumn:o.getArg(e,"column")};var t=this._findMapping(n,this._originalMappings,"originalLine","originalColumn",o.compareByOriginalPositions,o.getArg(e,"bias",SourceMapConsumer.GREATEST_LOWER_BOUND));if(t>=0){var i=this._originalMappings[t];if(i.source===n.source){return{line:o.getArg(i,"generatedLine",null),column:o.getArg(i,"generatedColumn",null),lastColumn:o.getArg(i,"lastGeneratedColumn",null)}}}return{line:null,column:null,lastColumn:null}};t=BasicSourceMapConsumer;function IndexedSourceMapConsumer(e,r){var n=e;if(typeof e==="string"){n=o.parseSourceMapInput(e)}var t=o.getArg(n,"version");var i=o.getArg(n,"sections");if(t!=this._version){throw new Error("Unsupported version: "+t)}this._sources=new a;this._names=new a;var u={line:-1,column:0};this._sections=i.map((function(e){if(e.url){throw new Error("Support for url field in sections not implemented.")}var n=o.getArg(e,"offset");var t=o.getArg(n,"line");var i=o.getArg(n,"column");if(t{var t=n(449);var o=n(339);var i=n(274).I;var a=n(680).H;function SourceMapGenerator(e){if(!e){e={}}this._file=o.getArg(e,"file",null);this._sourceRoot=o.getArg(e,"sourceRoot",null);this._skipValidation=o.getArg(e,"skipValidation",false);this._sources=new i;this._names=new i;this._mappings=new a;this._sourcesContents=null}SourceMapGenerator.prototype._version=3;SourceMapGenerator.fromSourceMap=function SourceMapGenerator_fromSourceMap(e){var r=e.sourceRoot;var n=new SourceMapGenerator({file:e.file,sourceRoot:r});e.eachMapping((function(e){var t={generated:{line:e.generatedLine,column:e.generatedColumn}};if(e.source!=null){t.source=e.source;if(r!=null){t.source=o.relative(r,t.source)}t.original={line:e.originalLine,column:e.originalColumn};if(e.name!=null){t.name=e.name}}n.addMapping(t)}));e.sources.forEach((function(t){var i=t;if(r!==null){i=o.relative(r,t)}if(!n._sources.has(i)){n._sources.add(i)}var a=e.sourceContentFor(t);if(a!=null){n.setSourceContent(t,a)}}));return n};SourceMapGenerator.prototype.addMapping=function SourceMapGenerator_addMapping(e){var r=o.getArg(e,"generated");var n=o.getArg(e,"original",null);var t=o.getArg(e,"source",null);var i=o.getArg(e,"name",null);if(!this._skipValidation){this._validateMapping(r,n,t,i)}if(t!=null){t=String(t);if(!this._sources.has(t)){this._sources.add(t)}}if(i!=null){i=String(i);if(!this._names.has(i)){this._names.add(i)}}this._mappings.add({generatedLine:r.line,generatedColumn:r.column,originalLine:n!=null&&n.line,originalColumn:n!=null&&n.column,source:t,name:i})};SourceMapGenerator.prototype.setSourceContent=function SourceMapGenerator_setSourceContent(e,r){var n=e;if(this._sourceRoot!=null){n=o.relative(this._sourceRoot,n)}if(r!=null){if(!this._sourcesContents){this._sourcesContents=Object.create(null)}this._sourcesContents[o.toSetString(n)]=r}else if(this._sourcesContents){delete this._sourcesContents[o.toSetString(n)];if(Object.keys(this._sourcesContents).length===0){this._sourcesContents=null}}};SourceMapGenerator.prototype.applySourceMap=function SourceMapGenerator_applySourceMap(e,r,n){var t=r;if(r==null){if(e.file==null){throw new Error("SourceMapGenerator.prototype.applySourceMap requires either an explicit source file, "+'or the source map\'s "file" property. Both were omitted.')}t=e.file}var a=this._sourceRoot;if(a!=null){t=o.relative(a,t)}var u=new i;var s=new i;this._mappings.unsortedForEach((function(r){if(r.source===t&&r.originalLine!=null){var i=e.originalPositionFor({line:r.originalLine,column:r.originalColumn});if(i.source!=null){r.source=i.source;if(n!=null){r.source=o.join(n,r.source)}if(a!=null){r.source=o.relative(a,r.source)}r.originalLine=i.line;r.originalColumn=i.column;if(i.name!=null){r.name=i.name}}}var l=r.source;if(l!=null&&!u.has(l)){u.add(l)}var c=r.name;if(c!=null&&!s.has(c)){s.add(c)}}),this);this._sources=u;this._names=s;e.sources.forEach((function(r){var t=e.sourceContentFor(r);if(t!=null){if(n!=null){r=o.join(n,r)}if(a!=null){r=o.relative(a,r)}this.setSourceContent(r,t)}}),this)};SourceMapGenerator.prototype._validateMapping=function SourceMapGenerator_validateMapping(e,r,n,t){if(r&&typeof r.line!=="number"&&typeof r.column!=="number"){throw new Error("original.line and original.column are not numbers -- you probably meant to omit "+"the original mapping entirely and only map the generated position. If so, pass "+"null for the original mapping instead of an object with empty or null values.")}if(e&&"line"in e&&"column"in e&&e.line>0&&e.column>=0&&!r&&!n&&!t){return}else if(e&&"line"in e&&"column"in e&&r&&"line"in r&&"column"in r&&e.line>0&&e.column>=0&&r.line>0&&r.column>=0&&n){return}else{throw new Error("Invalid mapping: "+JSON.stringify({generated:e,source:n,original:r,name:t}))}};SourceMapGenerator.prototype._serializeMappings=function SourceMapGenerator_serializeMappings(){var e=0;var r=1;var n=0;var i=0;var a=0;var u=0;var s="";var l;var c;var p;var f;var g=this._mappings.toArray();for(var h=0,d=g.length;h0){if(!o.compareByGeneratedPositionsInflated(c,g[h-1])){continue}l+=","}}l+=t.encode(c.generatedColumn-e);e=c.generatedColumn;if(c.source!=null){f=this._sources.indexOf(c.source);l+=t.encode(f-u);u=f;l+=t.encode(c.originalLine-1-i);i=c.originalLine-1;l+=t.encode(c.originalColumn-n);n=c.originalColumn;if(c.name!=null){p=this._names.indexOf(c.name);l+=t.encode(p-a);a=p}}s+=l}return s};SourceMapGenerator.prototype._generateSourcesContent=function SourceMapGenerator_generateSourcesContent(e,r){return e.map((function(e){if(!this._sourcesContents){return null}if(r!=null){e=o.relative(r,e)}var n=o.toSetString(e);return Object.prototype.hasOwnProperty.call(this._sourcesContents,n)?this._sourcesContents[n]:null}),this)};SourceMapGenerator.prototype.toJSON=function SourceMapGenerator_toJSON(){var e={version:this._version,sources:this._sources.toArray(),names:this._names.toArray(),mappings:this._serializeMappings()};if(this._file!=null){e.file=this._file}if(this._sourceRoot!=null){e.sourceRoot=this._sourceRoot}if(this._sourcesContents){e.sourcesContent=this._generateSourcesContent(e.sources,e.sourceRoot)}return e};SourceMapGenerator.prototype.toString=function SourceMapGenerator_toString(){return JSON.stringify(this.toJSON())};r.h=SourceMapGenerator},351:(e,r,n)=>{var t;var o=n(591).h;var i=n(339);var a=/(\r?\n)/;var u=10;var s="$$$isSourceNode$$$";function SourceNode(e,r,n,t,o){this.children=[];this.sourceContents={};this.line=e==null?null:e;this.column=r==null?null:r;this.source=n==null?null:n;this.name=o==null?null:o;this[s]=true;if(t!=null)this.add(t)}SourceNode.fromStringWithSourceMap=function SourceNode_fromStringWithSourceMap(e,r,n){var t=new SourceNode;var o=e.split(a);var u=0;var shiftNextLine=function(){var e=getNextLine();var r=getNextLine()||"";return e+r;function getNextLine(){return u=0;r--){this.prepend(e[r])}}else if(e[s]||typeof e==="string"){this.children.unshift(e)}else{throw new TypeError("Expected a SourceNode, string, or an array of SourceNodes and strings. Got "+e)}return this};SourceNode.prototype.walk=function SourceNode_walk(e){var r;for(var n=0,t=this.children.length;n0){r=[];for(n=0;n{function getArg(e,r,n){if(r in e){return e[r]}else if(arguments.length===3){return n}else{throw new Error('"'+r+'" is a required argument.')}}r.getArg=getArg;var n=/^(?:([\w+\-.]+):)?\/\/(?:(\w+:\w+)@)?([\w.-]*)(?::(\d+))?(.*)$/;var t=/^data:.+\,.+$/;function urlParse(e){var r=e.match(n);if(!r){return null}return{scheme:r[1],auth:r[2],host:r[3],port:r[4],path:r[5]}}r.urlParse=urlParse;function urlGenerate(e){var r="";if(e.scheme){r+=e.scheme+":"}r+="//";if(e.auth){r+=e.auth+"@"}if(e.host){r+=e.host}if(e.port){r+=":"+e.port}if(e.path){r+=e.path}return r}r.urlGenerate=urlGenerate;function normalize(e){var n=e;var t=urlParse(e);if(t){if(!t.path){return e}n=t.path}var o=r.isAbsolute(n);var i=n.split(/\/+/);for(var a,u=0,s=i.length-1;s>=0;s--){a=i[s];if(a==="."){i.splice(s,1)}else if(a===".."){u++}else if(u>0){if(a===""){i.splice(s+1,u);u=0}else{i.splice(s,2);u--}}}n=i.join("/");if(n===""){n=o?"/":"."}if(t){t.path=n;return urlGenerate(t)}return n}r.normalize=normalize;function join(e,r){if(e===""){e="."}if(r===""){r="."}var n=urlParse(r);var o=urlParse(e);if(o){e=o.path||"/"}if(n&&!n.scheme){if(o){n.scheme=o.scheme}return urlGenerate(n)}if(n||r.match(t)){return r}if(o&&!o.host&&!o.path){o.host=r;return urlGenerate(o)}var i=r.charAt(0)==="/"?r:normalize(e.replace(/\/+$/,"")+"/"+r);if(o){o.path=i;return urlGenerate(o)}return i}r.join=join;r.isAbsolute=function(e){return e.charAt(0)==="/"||n.test(e)};function relative(e,r){if(e===""){e="."}e=e.replace(/\/$/,"");var n=0;while(r.indexOf(e+"/")!==0){var t=e.lastIndexOf("/");if(t<0){return r}e=e.slice(0,t);if(e.match(/^([^\/]+:\/)?\/*$/)){return r}++n}return Array(n+1).join("../")+r.substr(e.length+1)}r.relative=relative;var o=function(){var e=Object.create(null);return!("__proto__"in e)}();function identity(e){return e}function toSetString(e){if(isProtoString(e)){return"$"+e}return e}r.toSetString=o?identity:toSetString;function fromSetString(e){if(isProtoString(e)){return e.slice(1)}return e}r.fromSetString=o?identity:fromSetString;function isProtoString(e){if(!e){return false}var r=e.length;if(r<9){return false}if(e.charCodeAt(r-1)!==95||e.charCodeAt(r-2)!==95||e.charCodeAt(r-3)!==111||e.charCodeAt(r-4)!==116||e.charCodeAt(r-5)!==111||e.charCodeAt(r-6)!==114||e.charCodeAt(r-7)!==112||e.charCodeAt(r-8)!==95||e.charCodeAt(r-9)!==95){return false}for(var n=r-10;n>=0;n--){if(e.charCodeAt(n)!==36){return false}}return true}function compareByOriginalPositions(e,r,n){var t=strcmp(e.source,r.source);if(t!==0){return t}t=e.originalLine-r.originalLine;if(t!==0){return t}t=e.originalColumn-r.originalColumn;if(t!==0||n){return t}t=e.generatedColumn-r.generatedColumn;if(t!==0){return t}t=e.generatedLine-r.generatedLine;if(t!==0){return t}return strcmp(e.name,r.name)}r.compareByOriginalPositions=compareByOriginalPositions;function compareByGeneratedPositionsDeflated(e,r,n){var t=e.generatedLine-r.generatedLine;if(t!==0){return t}t=e.generatedColumn-r.generatedColumn;if(t!==0||n){return t}t=strcmp(e.source,r.source);if(t!==0){return t}t=e.originalLine-r.originalLine;if(t!==0){return t}t=e.originalColumn-r.originalColumn;if(t!==0){return t}return strcmp(e.name,r.name)}r.compareByGeneratedPositionsDeflated=compareByGeneratedPositionsDeflated;function strcmp(e,r){if(e===r){return 0}if(e===null){return 1}if(r===null){return-1}if(e>r){return 1}return-1}function compareByGeneratedPositionsInflated(e,r){var n=e.generatedLine-r.generatedLine;if(n!==0){return n}n=e.generatedColumn-r.generatedColumn;if(n!==0){return n}n=strcmp(e.source,r.source);if(n!==0){return n}n=e.originalLine-r.originalLine;if(n!==0){return n}n=e.originalColumn-r.originalColumn;if(n!==0){return n}return strcmp(e.name,r.name)}r.compareByGeneratedPositionsInflated=compareByGeneratedPositionsInflated;function parseSourceMapInput(e){return JSON.parse(e.replace(/^\)]}'[^\n]*\n/,""))}r.parseSourceMapInput=parseSourceMapInput;function computeSourceURL(e,r,n){r=r||"";if(e){if(e[e.length-1]!=="/"&&r[0]!=="/"){e+="/"}r=e+r}if(n){var t=urlParse(n);if(!t){throw new Error("sourceMapURL could not be parsed")}if(t.path){var o=t.path.lastIndexOf("/");if(o>=0){t.path=t.path.substring(0,o+1)}}r=join(urlGenerate(t),r)}return normalize(r)}r.computeSourceURL=computeSourceURL},997:(e,r,n)=>{n(591).h;r.SourceMapConsumer=n(952).SourceMapConsumer;n(351)},284:(e,r,n)=>{e=n.nmd(e);var t=n(997).SourceMapConsumer;var o=n(17);var i;try{i=n(147);if(!i.existsSync||!i.readFileSync){i=null}}catch(e){}var a=n(650);function dynamicRequire(e,r){return e.require(r)}var u=false;var s=false;var l=false;var c="auto";var p={};var f={};var g=/^data:application\/json[^,]+base64,/;var h=[];var d=[];function isInBrowser(){if(c==="browser")return true;if(c==="node")return false;return typeof window!=="undefined"&&typeof XMLHttpRequest==="function"&&!(window.require&&window.module&&window.process&&window.process.type==="renderer")}function hasGlobalProcessEventEmitter(){return typeof process==="object"&&process!==null&&typeof process.on==="function"}function globalProcessVersion(){if(typeof process==="object"&&process!==null){return process.version}else{return""}}function globalProcessStderr(){if(typeof process==="object"&&process!==null){return process.stderr}}function globalProcessExit(e){if(typeof process==="object"&&process!==null&&typeof process.exit==="function"){return process.exit(e)}}function handlerExec(e){return function(r){for(var n=0;n"}var n=this.getLineNumber();if(n!=null){r+=":"+n;var t=this.getColumnNumber();if(t){r+=":"+t}}}var o="";var i=this.getFunctionName();var a=true;var u=this.isConstructor();var s=!(this.isToplevel()||u);if(s){var l=this.getTypeName();if(l==="[object Object]"){l="null"}var c=this.getMethodName();if(i){if(l&&i.indexOf(l)!=0){o+=l+"."}o+=i;if(c&&i.indexOf("."+c)!=i.length-c.length-1){o+=" [as "+c+"]"}}else{o+=l+"."+(c||"")}}else if(u){o+="new "+(i||"")}else if(i){o+=i}else{o+=r;a=false}if(a){o+=" ("+r+")"}return o}function cloneCallSite(e){var r={};Object.getOwnPropertyNames(Object.getPrototypeOf(e)).forEach((function(n){r[n]=/^(?:is|get)/.test(n)?function(){return e[n].call(e)}:e[n]}));r.toString=CallSiteToString;return r}function wrapCallSite(e,r){if(r===undefined){r={nextPosition:null,curPosition:null}}if(e.isNative()){r.curPosition=null;return e}var n=e.getFileName()||e.getScriptNameOrSourceURL();if(n){var t=e.getLineNumber();var o=e.getColumnNumber()-1;var i=/^v(10\.1[6-9]|10\.[2-9][0-9]|10\.[0-9]{3,}|1[2-9]\d*|[2-9]\d|\d{3,}|11\.11)/;var a=i.test(globalProcessVersion())?0:62;if(t===1&&o>a&&!isInBrowser()&&!e.isEval()){o-=a}var u=mapSourcePosition({source:n,line:t,column:o});r.curPosition=u;e=cloneCallSite(e);var s=e.getFunctionName;e.getFunctionName=function(){if(r.nextPosition==null){return s()}return r.nextPosition.name||s()};e.getFileName=function(){return u.source};e.getLineNumber=function(){return u.line};e.getColumnNumber=function(){return u.column+1};e.getScriptNameOrSourceURL=function(){return u.source};return e}var l=e.isEval()&&e.getEvalOrigin();if(l){l=mapEvalOrigin(l);e=cloneCallSite(e);e.getEvalOrigin=function(){return l};return e}return e}function prepareStackTrace(e,r){if(l){p={};f={}}var n=e.name||"Error";var t=e.message||"";var o=n+": "+t;var i={nextPosition:null,curPosition:null};var a=[];for(var u=r.length-1;u>=0;u--){a.push("\n at "+wrapCallSite(r[u],i));i.nextPosition=i.curPosition}i.curPosition=i.nextPosition=null;return o+a.reverse().join("")}function getErrorSource(e){var r=/\n at [^(]+ \((.*):(\d+):(\d+)\)/.exec(e.stack);if(r){var n=r[1];var t=+r[2];var o=+r[3];var a=p[n];if(!a&&i&&i.existsSync(n)){try{a=i.readFileSync(n,"utf8")}catch(e){a=""}}if(a){var u=a.split(/(?:\r\n|\r|\n)/)[t-1];if(u){return n+":"+t+"\n"+u+"\n"+new Array(o).join(" ")+"^"}}}return null}function printErrorAndExit(e){var r=getErrorSource(e);var n=globalProcessStderr();if(n&&n._handle&&n._handle.setBlocking){n._handle.setBlocking(true)}if(r){console.error();console.error(r)}console.error(e.stack);globalProcessExit(1)}function shimEmitUncaughtException(){var e=process.emit;process.emit=function(r){if(r==="uncaughtException"){var n=arguments[1]&&arguments[1].stack;var t=this.listeners(r).length>0;if(n&&!t){return printErrorAndExit(arguments[1])}}return e.apply(this,arguments)}}var S=h.slice(0);var _=d.slice(0);r.wrapCallSite=wrapCallSite;r.getErrorSource=getErrorSource;r.mapSourcePosition=mapSourcePosition;r.retrieveSourceMap=v;r.install=function(r){r=r||{};if(r.environment){c=r.environment;if(["node","browser","auto"].indexOf(c)===-1){throw new Error("environment "+c+" was unknown. Available options are {auto, browser, node}")}}if(r.retrieveFile){if(r.overrideRetrieveFile){h.length=0}h.unshift(r.retrieveFile)}if(r.retrieveSourceMap){if(r.overrideRetrieveSourceMap){d.length=0}d.unshift(r.retrieveSourceMap)}if(r.hookRequire&&!isInBrowser()){var n=dynamicRequire(e,"module");var t=n.prototype._compile;if(!t.__sourceMapSupport){n.prototype._compile=function(e,r){p[r]=e;f[r]=undefined;return t.call(this,e,r)};n.prototype._compile.__sourceMapSupport=true}}if(!l){l="emptyCacheBetweenOperations"in r?r.emptyCacheBetweenOperations:false}if(!u){u=true;Error.prepareStackTrace=prepareStackTrace}if(!s){var o="handleUncaughtExceptions"in r?r.handleUncaughtExceptions:true;try{var i=dynamicRequire(e,"worker_threads");if(i.isMainThread===false){o=false}}catch(e){}if(o&&hasGlobalProcessEventEmitter()){s=true;shimEmitUncaughtException()}}};r.resetRetrieveHandlers=function(){h.length=0;d.length=0;h=S.slice(0);d=_.slice(0);v=handlerExec(d);m=handlerExec(h)}},147:e=>{"use strict";e.exports=require("fs")},17:e=>{"use strict";e.exports=require("path")}};var r={};function __webpack_require__(n){var t=r[n];if(t!==undefined){return t.exports}var o=r[n]={id:n,loaded:false,exports:{}};var i=true;try{e[n](o,o.exports,__webpack_require__);i=false}finally{if(i)delete r[n]}o.loaded=true;return o.exports}(()=>{__webpack_require__.nmd=e=>{e.paths=[];if(!e.children)e.children=[];return e}})();if(typeof __webpack_require__!=="undefined")__webpack_require__.ab=__dirname+"/";var n={};(()=>{__webpack_require__(284).install()})();module.exports=n})(); -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # These example OPA policies are taken from [Spacelift's example policy library](https://github.com/spacelift-io/spacelift-policies-example-library) 2 | -------------------------------------------------------------------------------- /examples/cancel-in-progress-runs.rego: -------------------------------------------------------------------------------- 1 | package spacelift 2 | 3 | # The push policy can be used to have the new run pre-empt any runs that are 4 | # currently in progress. The input document includes the in_progress key, which 5 | # contains an array of runs that are currently either still queued or are awaiting 6 | # human confirmation. You can use it in conjunction with the cancel rule like this: 7 | cancel[run.id] { 8 | run := input.in_progress[_] 9 | run.type == "PROPOSED" 10 | run.state == "QUEUED" 11 | run.branch == input.pull_request.head.branch 12 | } 13 | 14 | # Learn more about sampling policy evaluations here: 15 | # https://docs.spacelift.io/concepts/policy#sampling-policy-inputs 16 | sample := true 17 | -------------------------------------------------------------------------------- /examples/do-not-delete-stateful-resources.rego: -------------------------------------------------------------------------------- 1 | package spacelift 2 | 3 | import future.keywords.in 4 | 5 | # This policy is a plan policy, it will validate the resources during the plan phase. 6 | # More details at: https://docs.spacelift.io/concepts/policy/terraform-plan-policy 7 | # The "deny" rule fires when a specified resource is being deleted. 8 | # The result is a formatted message with the address of the offending resource. 9 | 10 | deny[sprintf(message, [resource.address])] { 11 | # Define the error message format 12 | message := "do not delete %s" 13 | 14 | # Loop over each resource change in the plan 15 | resource := input.terraform.resource_changes[_] 16 | 17 | # Define a set of resource types for which deletions should be prevented. 18 | prevent_delete := { 19 | "aws_db_instance", 20 | "aws_efs_file_system", 21 | "aws_dynamodb_table", 22 | "aws_s3_bucket", 23 | } 24 | 25 | # Check if the resource type is one of those defined in prevent_delete 26 | prevent_delete[resource.type] 27 | 28 | # Check if any of the actions on the resource is "delete" 29 | "delete" in resource.change.actions 30 | } 31 | 32 | # Learn more about sampling policy evaluations here: 33 | # https://docs.spacelift.io/concepts/policy#sampling-policy-inputs 34 | sample := true 35 | -------------------------------------------------------------------------------- /examples/drift-detection.rego: -------------------------------------------------------------------------------- 1 | package spacelift 2 | 3 | slack[{"channel_id": "C000000"}] { 4 | # Checking if drift detection is present in the run update 5 | input.run_updated.run.drift_detection 6 | 7 | # Condition to verify that there is at least one change 8 | count(input.run_updated.run.changes) > 0 9 | } 10 | 11 | sample := true 12 | -------------------------------------------------------------------------------- /examples/enforce-module-use-policy.rego: -------------------------------------------------------------------------------- 1 | package spacelift 2 | 3 | import future.keywords.in 4 | 5 | # Note: This policy requires the configuration of your terraform state to be provided. In this policy, 6 | # we reference this via `input.third_party_metadata.custom.configuration` (line 56 below) 7 | # which is provided to the policy if you follow the instructions documented here: 8 | # https://docs.spacelift.io/concepts/policy/terraform-plan-policy#example-exposing-terraform-configuration-to-the-plan-policy 9 | 10 | # TODO: Upgrade to take into account approved versions of the approved modules, and implement 11 | # ability to warn on deprecated versions, and deny on no longer supported versions! 12 | 13 | # This is a map of resource types and the list of modules which are 14 | # approved to be used to create them. Note, you do not need to allow explicitly 15 | # any "wrapper" modules... this is checking the immediate module parent of the resource itself 16 | # Some example resources and approved module(s), you of course can specify your spacelift.io hosted modules 17 | controlled_resource_types := { 18 | "aws_s3_bucket": ["terraform-aws-modules/s3-bucket/aws"], 19 | "aws_s3_bucket_acl": ["terraform-aws-modules/s3-bucket/aws"], 20 | "aws_s3_bucket_website_configuration": ["terraform-aws-modules/s3-bucket/aws"], 21 | } 22 | 23 | # Deny ability to create the resource directly (aka not in a module we identify) 24 | deny[reason] { 25 | resource := input.terraform.resource_changes[_] 26 | actions := {"create", "update"} 27 | actions[resource.change.actions[_]] 28 | module_source = controlled_resource_types[resource.type] 29 | not resource.module_address 30 | reason := sprintf( 31 | "Resource '%s' cannot be created directly. Module(s) '%s' must be used instead", 32 | [resource.address, concat("', '", controlled_resource_types[resource.type])], 33 | ) 34 | } 35 | 36 | # Deny ability to create the resource in an unapproved module 37 | deny[failed_reasons] { 38 | # Did any of the resources fail to pass? 39 | count(invalid_resources[_]) > 0 40 | 41 | # Build a list of reasons for each failure 42 | failed_reasons := [sprintf( 43 | "Resource '%s' in top level module named '%s' is being created with the incorrect terraform module '%s'. Module(s) '%s' must be used instead.", 44 | [reason.resource_type, reason.top_level_module_name, reason.resource_module_name, concat("', '", controlled_resource_types[reason.resource_type])], 45 | ) | 46 | reason := invalid_resources[_] 47 | ][_] 48 | } 49 | 50 | # Walk the "configuration" tree and find all the resources which appear in our 51 | # "controlled_resource_types" 52 | controlled_resources[resource] { 53 | # Recursively walk the module hierarchy 54 | [path, module_ref] := walk(input.third_party_metadata.custom.configuration) 55 | 56 | # Filter out only objects that are modules, i.e. have a source property 57 | source := module_ref.source 58 | 59 | # Get all resources created in the module and their types 60 | resources := module_ref.module.resources 61 | resource_type := resources[_].type 62 | 63 | # Filter out resources that are considered controlled 64 | resource_type in object.keys(controlled_resource_types) 65 | 66 | # Create an object with the module and the resource type, 67 | # this is a set so duplicates will be removed based on this object 68 | resource := { 69 | "resource_module_name": source, 70 | "top_level_module_name": path[2], 71 | "resource_type": resource_type, 72 | "resource_module_version_constraint": module_ref.version_constraint, 73 | } 74 | } 75 | 76 | # When the controlled resources are collected, iterate through them and 77 | # see if they comply 78 | invalid_resources[resource_instance] { 79 | resource_instance := controlled_resources[_] 80 | not resource_instance.resource_module_name in controlled_resource_types[resource_instance.resource_type] 81 | } 82 | -------------------------------------------------------------------------------- /examples/enforce-password-length.rego: -------------------------------------------------------------------------------- 1 | package spacelift 2 | 3 | import future.keywords.in 4 | 5 | # This example plan policy prevents you from creating weak passwords, and warns 6 | # you when passwords are meh. 7 | # 8 | # You can read more about plan policies here: 9 | # https://docs.spacelift.io/concepts/policy/terraform-plan-policy 10 | 11 | deny[sprintf("We require that passwords have at least 16 characters (%s)", [resource.address])] { 12 | resource := new_password[_] 13 | resource.change.after.length < 16 14 | } 15 | 16 | warn[sprintf("We advise that passwords have at least 20 characters (%s)", [resource.address])] { 17 | resource := new_password[_] 18 | resource.change.after.length < 20 19 | } 20 | 21 | new_password[resource] { 22 | resource := input.terraform.resource_changes[_] 23 | "create" in resource.change.actions 24 | resource.type == "random_password" 25 | } 26 | 27 | # Learn more about sampling policy evaluations here: 28 | # https://docs.spacelift.io/concepts/policy#sampling-policy-inputs 29 | sample := true 30 | -------------------------------------------------------------------------------- /examples/ignore-changes-outside-root.rego: -------------------------------------------------------------------------------- 1 | package spacelift 2 | 3 | import rego.v1 4 | 5 | # This example Git push policy ignores all changes that are outside a project's 6 | # root. Other than that, it follows the defaults - pushes to the tracked branch 7 | # trigger tracked runs, pushes to all other branches trigger proposed runs, tag 8 | # pushes are ignored. 9 | # 10 | # You can read more about push policies here: 11 | # https://docs.spacelift.io/concepts/policy/git-push-policy 12 | 13 | track if { 14 | affected 15 | input.push.branch == input.stack.branch 16 | } 17 | 18 | propose if { 19 | affected 20 | } 21 | 22 | ignore if { 23 | not affected 24 | } 25 | 26 | ignore if { 27 | input.push.tag != "" 28 | } 29 | 30 | # Here's a definition of an affected file - its path must both: 31 | # a) start with the Stack's project root, and; 32 | # b) end with ".tf", indicating that it's a Terraform source file; 33 | affected if { 34 | filepath := input.push.affected_files[_] 35 | 36 | startswith(filepath, input.stack.project_root) 37 | endswith(filepath, ".tf") 38 | } 39 | 40 | # Learn more about sampling policy evaluations here: 41 | # https://docs.spacelift.io/concepts/policy#sampling-policy-inputs 42 | sample := true 43 | -------------------------------------------------------------------------------- /examples/notification-stack-failure-origins.rego: -------------------------------------------------------------------------------- 1 | # Notify author of PR/commit which potentially introduced failure of a Spacelift Stack 2 | 3 | package spacelift 4 | 5 | import future.keywords.contains 6 | import future.keywords.if 7 | 8 | run_url := sprintf( 9 | "https://%s.app.spacelift.io/stack/%s/run/%s", 10 | [input.account.name, input.run_updated.stack.id, input.run_updated.run.id], 11 | ) 12 | 13 | # Mapping of GitHub usernames to Slack user IDs since display names can't be tagged 14 | # https://api.slack.com/reference/surfaces/formatting#mentioning-users 15 | github_to_slack := { 16 | "GITHUB_USERNAME_EXAMPLE1": "SLACK_ID_EXAMPLE1", 17 | "GITHUB_USERNAME_EXAMPLE2": "SLACK_ID_EXAMPLE2", 18 | } 19 | 20 | github_author := input.run_updated.run.commit.author 21 | 22 | # Function to get Slack ID corresponding to GH username from the map if exists, else, mention @here for Slack notification 23 | retrieve_slack_user_id(github_username) := slack_id if { 24 | slack_id := github_to_slack[github_username] 25 | } else := "here" 26 | 27 | # Ensure this is a tracked run and it failed 28 | tracked_failed if { 29 | input.run_updated.run.type == "TRACKED" 30 | input.run_updated.run.state == "FAILED" 31 | } 32 | 33 | # Notify via Slack of the failure, mentioning author of the tracked commit that Spacelift ran on 34 | # Hyperlinks defined as - https://api.slack.com/reference/surfaces/formatting#linking-urls 35 | slack contains { 36 | "channel_id": "YOUR_SLACK_CHANNEL", 37 | "message": sprintf( 38 | concat("", [ 39 | "⚠️Spacelift Stack Failure⚠️ - `%s`\n", 40 | "Hey <@%s>, your commit/PR introduced changes that potentially caused the failure of the `%s` stack.\n", 41 | "Check out the <%s|run details here.>\n", 42 | "Tracked Commit: <%s|%s>", 43 | ]), 44 | [ 45 | input.run_updated.stack.name, 46 | slack_user_id, 47 | input.run_updated.stack.name, 48 | run_url, 49 | input.run_updated.run.commit.url, 50 | input.run_updated.run.commit.hash, 51 | ], 52 | ), 53 | } if { 54 | tracked_failed 55 | slack_user_id := retrieve_slack_user_id(github_author) 56 | } 57 | 58 | # Write a comment on the PR that introduced the change where this stack failed 59 | # Use `deduplication_key` to update existing comment related to stack in place, so multiple failures of the same stack won't comment again 60 | pull_request contains { 61 | "commit": input.run_updated.run.commit.hash, 62 | "body": sprintf( 63 | concat("", [ 64 | "## ⚠️Spacelift Stack Failure⚠️\n", 65 | "@%s - This pull request was deployed and the following Spacelift stack failed:\n", 66 | "* ⛔️ `%s` - [view the run here](%s) ⛔️", 67 | ]), 68 | [ 69 | github_author, 70 | input.run_updated.stack.id, 71 | run_url, 72 | ], 73 | ), 74 | "deduplication_key": deduplication_key, 75 | } if { 76 | tracked_failed 77 | deduplication_key := input.run_updated.stack.id 78 | } 79 | 80 | sample := true 81 | -------------------------------------------------------------------------------- /examples/readers-writers-admins-teams.rego: -------------------------------------------------------------------------------- 1 | package spacelift 2 | 3 | import future.keywords.contains 4 | import future.keywords.if 5 | import future.keywords.in 6 | 7 | # Define team roles 8 | admins := {"team1", "team2", "team3"} 9 | 10 | writers := {"team4", "team5", "team6"} 11 | 12 | readers := {"team7", "team8", "team9"} 13 | 14 | # Space access rules 15 | # Admin access rule - highest priority 16 | space_admin contains space.id if { 17 | some space in input.spaces 18 | some login in input.session.teams 19 | admins[login] # User is an admin 20 | } 21 | 22 | # Writer access rule - second priority 23 | # Only consider this rule if the user is not an admin 24 | space_write contains space.id if { 25 | some space in input.spaces 26 | some login in input.session.teams 27 | writers[login] # User is a writer 28 | not admins[login] # Ensure user is not an admin 29 | } 30 | 31 | # Reader access rule - third priority 32 | # Only consider this rule if the user is neither an admin nor a writer 33 | space_read contains space.id if { 34 | some space in input.spaces 35 | some login in input.session.teams 36 | readers[login] # User is a reader 37 | not admins[login] # Ensure user is not an admin 38 | not writers[login] # Ensure user is not a writer 39 | } 40 | -------------------------------------------------------------------------------- /examples/tests/cancel-in-progress-runs_test.rego: -------------------------------------------------------------------------------- 1 | package spacelift 2 | 3 | test_cancel_runs_allowed { 4 | cancel.test with input as { 5 | "pull_request": {"head": {"branch": "main"}}, 6 | "in_progress": [{ 7 | "id": "test", 8 | "type": "PROPOSED", 9 | "state": "QUEUED", 10 | "branch": "main", 11 | }], 12 | } 13 | } 14 | 15 | test_cancel_runs_denied { 16 | not cancel.test with input as { 17 | "pull_request": {"head": {"branch": "feature/example"}}, 18 | "in_progress": [{ 19 | "id": "test", 20 | "type": "PROPOSED", 21 | "state": "QUEUED", 22 | "branch": "main", 23 | }], 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/tests/do-not-delete-stateful-resources_test.rego: -------------------------------------------------------------------------------- 1 | package spacelift 2 | 3 | # Test case for an aws_s3_bucket being deleted 4 | test_deny_s3_bucket_deletion { 5 | # Assert deny rule fires with the expected message 6 | deny["do not delete aws_s3_bucket.my_bucket"] with input as {"terraform": {"resource_changes": [{ 7 | "address": "aws_s3_bucket.my_bucket", 8 | "type": "aws_s3_bucket", 9 | "change": {"actions": ["delete"]}, 10 | }]}} 11 | } 12 | 13 | # Test case for an aws_db_instance being deleted 14 | test_deny_db_instance_deletion { 15 | # Assert deny rule fires with the expected message 16 | deny["do not delete aws_db_instance.my_rds"] with input as {"terraform": {"resource_changes": [{ 17 | "address": "aws_db_instance.my_rds", 18 | "type": "aws_db_instance", 19 | "change": {"actions": ["delete"]}, 20 | }]}} 21 | } 22 | 23 | # Test case for an aws_efs_file_system being deleted 24 | test_deny_efs_file_system_deletion { 25 | # Assert deny rule fires with the expected message 26 | deny["do not delete aws_efs_file_system.my_efs"] with input as {"terraform": {"resource_changes": [{ 27 | "address": "aws_efs_file_system.my_efs", 28 | "type": "aws_efs_file_system", 29 | "change": {"actions": ["delete"]}, 30 | }]}} 31 | } 32 | 33 | # Test case for an aws_dynamodb_table being deleted 34 | test_deny_dynamodb_table_deletion { 35 | # Assert deny rule fires with the expected message 36 | deny["do not delete aws_dynamodb_table.my_table"] with input as {"terraform": {"resource_changes": [{ 37 | "address": "aws_dynamodb_table.my_table", 38 | "type": "aws_dynamodb_table", 39 | "change": {"actions": ["delete"]}, 40 | }]}} 41 | } 42 | 43 | # Test case for an aws_instance being deleted (which should not be denied) 44 | test_allow_instance_deletion { 45 | # Assert deny rule does not fire 46 | count(deny) == 0 with input as {"terraform": {"resource_changes": [{ 47 | "address": "aws_instance.my_instance", 48 | "type": "aws_instance", 49 | "change": {"actions": ["delete"]}, 50 | }]}} 51 | } 52 | -------------------------------------------------------------------------------- /examples/tests/enforce-module-use-policy_test.rego: -------------------------------------------------------------------------------- 1 | package spacelift 2 | 3 | # Test case for denying creation of controlled resource type. 4 | test_deny_creation_of_controlled_resource_type { 5 | deny["Resource 'aws_s3_bucket.bucket_1' cannot be created directly. Module(s) 'terraform-aws-modules/s3-bucket/aws' must be used instead"] with input as {"terraform": {"resource_changes": [{ 6 | "address": "aws_s3_bucket.bucket_1", 7 | "type": "aws_s3_bucket", 8 | "change": {"actions": ["create"]}, 9 | }]}} 10 | } 11 | 12 | # Test case for update creation of controlled resource type. 13 | test_deny_update_of_controlled_resource_type { 14 | deny["Resource 'aws_s3_bucket.bucket_1' cannot be created directly. Module(s) 'terraform-aws-modules/s3-bucket/aws' must be used instead"] with input as {"terraform": {"resource_changes": [{ 15 | "address": "aws_s3_bucket.bucket_1", 16 | "type": "aws_s3_bucket", 17 | "change": {"actions": ["update"]}, 18 | }]}} 19 | } 20 | 21 | # Test case for allowing deletion of controlled resource type. 22 | # TO ADD BACK 23 | 24 | # Test case for allowing creation of uncontrolled resource type. 25 | test_allow_creation_of_uncontrolled_resource_type { 26 | count(deny) == 0 with input as {"terraform": {"resource_changes": [{ 27 | "address": "aws_ecs_cluster.one", 28 | "type": "aws_ecs_cluster", 29 | "change": {"actions": ["create"]}, 30 | }]}} 31 | } 32 | -------------------------------------------------------------------------------- /examples/tests/enforce-password-length_test.rego: -------------------------------------------------------------------------------- 1 | package spacelift 2 | 3 | # Test case for denying creation of a password with less than 16 characters. 4 | test_deny_creation_of_password_with_less_than_16_characters { 5 | deny["We require that passwords have at least 16 characters (random_password.password_1)"] with input as {"terraform": {"resource_changes": [{ 6 | "address": "random_password.password_1", 7 | "type": "random_password", 8 | "change": { 9 | "actions": ["create"], 10 | "after": {"length": 1}, 11 | }, 12 | }]}} 13 | } 14 | 15 | # Test case for warning creation of a password between 16 and 20 characters. 16 | test_warn_creation_of_password_between_16_and_20_characters { 17 | warn["We advise that passwords have at least 20 characters (random_password.password_1)"] with input as {"terraform": {"resource_changes": [{ 18 | "address": "random_password.password_1", 19 | "type": "random_password", 20 | "change": { 21 | "actions": ["create"], 22 | "after": {"length": 18}, 23 | }, 24 | }]}} 25 | } 26 | 27 | # Test case for allowing creation of a password longer than 20 characters. 28 | test_allow_creation_of_password_longer_than_20_characters { 29 | count(warn) == 0 with input as {"terraform": {"resource_changes": [{ 30 | "address": "random_password.password_1", 31 | "type": "random_password", 32 | "change": { 33 | "actions": ["create"], 34 | "after": {"length": 21}, 35 | }, 36 | }]}} 37 | } 38 | -------------------------------------------------------------------------------- /examples/tests/ignore-changes-outside-root_test.rego: -------------------------------------------------------------------------------- 1 | package spacelift 2 | 3 | test_affected_no_files { 4 | not affected with input as { 5 | "stack": {"project_root": ""}, 6 | "push": {"affected_files": []}, 7 | } 8 | } 9 | 10 | test_affected_tf_files { 11 | affected with input as { 12 | "stack": {"project_root": ""}, 13 | "push": {"affected_files": ["main.tf", "stacks.tf"]}, 14 | } 15 | } 16 | 17 | test_affected_no_tf_files { 18 | not affected with input as { 19 | "stack": {"project_root": ""}, 20 | "push": {"affected_files": ["README", "myicon.png"]}, 21 | } 22 | } 23 | 24 | test_affected_outside_project_root { 25 | not affected with input as { 26 | "stack": {"project_root": "stacks/my-stack"}, 27 | "push": {"affected_files": ["stacks/another-stack/main.tf"]}, 28 | } 29 | } 30 | 31 | test_ignore_affected { 32 | ignore with affected as false 33 | } 34 | 35 | test_ignore_not_affected { 36 | not ignore with affected as true 37 | } 38 | 39 | test_ignore_tag { 40 | ignore with input as {"push": {"tag": "v1.0.0"}} 41 | with affected as true 42 | } 43 | 44 | test_propose_affected { 45 | propose with affected as true 46 | } 47 | 48 | test_propose_not_affected { 49 | not propose with affected as false 50 | } 51 | 52 | matching_branch_input := { 53 | "push": {"branch": "main"}, 54 | "stack": {"branch": "main"}, 55 | } 56 | 57 | test_track_affected { 58 | track with input as matching_branch_input with affected as true 59 | } 60 | 61 | test_track_not_affected { 62 | not track with input as matching_branch_input with affected as false 63 | } 64 | 65 | test_track_not_stack_branch { 66 | not track with input as { 67 | "push": {"branch": "my-feature"}, 68 | "stack": {"branch": "main"}, 69 | } 70 | with affected as true 71 | } 72 | -------------------------------------------------------------------------------- /examples/tests/notification-stack-failure-origins_test.rego: -------------------------------------------------------------------------------- 1 | 2 | package spacelift 3 | 4 | import future.keywords.contains 5 | import future.keywords.if 6 | import future.keywords.in 7 | 8 | import data.spacelift 9 | 10 | # Test successful Slack notification for a tracked, failed run 11 | test_slack_notification_for_tracked_failed_run if { 12 | result := spacelift.slack with input as { 13 | "account": {"name": "test-account"}, 14 | "run_updated": { 15 | "stack": {"id": "test-stack", "name": "Test Stack"}, 16 | "run": { 17 | "id": "test-run", 18 | "type": "TRACKED", 19 | "state": "FAILED", 20 | "commit": { 21 | "author": "GITHUB_USERNAME_EXAMPLE1", 22 | "hash": "abcdef123456", 23 | "url": "https://github.com/org/repo/commit/abcdef123456", 24 | }, 25 | }, 26 | }, 27 | } 28 | 29 | count(result) == 1 30 | slack_message := result[_] 31 | slack_message.channel_id == "YOUR_SLACK_CHANNEL" 32 | contains(slack_message.message, "⚠️Spacelift Stack Failure⚠️ - `Test Stack`") 33 | contains(slack_message.message, "Hey <@SLACK_ID_EXAMPLE1>") 34 | contains(slack_message.message, "https://test-account.app.spacelift.io/stack/test-stack/run/test-run") 35 | } 36 | 37 | # Test no Slack notification for a non-tracked run 38 | test_no_slack_notification_for_non_tracked_run if { 39 | result := spacelift.slack with input as {"run_updated": {"run": { 40 | "type": "PROPOSED", 41 | "state": "FAILED", 42 | }}} 43 | 44 | count(result) == 0 45 | } 46 | 47 | # Test no Slack notification for a successful FINISHED run 48 | test_no_slack_notification_for_successful_run if { 49 | result := spacelift.slack with input as {"run_updated": {"run": { 50 | "type": "TRACKED", 51 | "state": "FINISHED", 52 | }}} 53 | 54 | count(result) == 0 55 | } 56 | 57 | # Test Slack notification with unknown GitHub user which should route to @here 58 | test_slack_notification_with_unknown_github_user if { 59 | result := spacelift.slack with input as { 60 | "account": {"name": "test-account"}, 61 | "run_updated": { 62 | "stack": {"id": "test-stack", "name": "Test Stack"}, 63 | "run": { 64 | "id": "test-run", 65 | "type": "TRACKED", 66 | "state": "FAILED", 67 | "commit": { 68 | "author": "unknown-user", 69 | "hash": "abcdef123456", 70 | "url": "https://github.com/org/repo/commit/abcdef123456", 71 | }, 72 | }, 73 | }, 74 | } 75 | 76 | count(result) == 1 77 | slack_message := result[_] 78 | contains(slack_message.message, "Hey <@here>") 79 | } 80 | 81 | # Test PR comment for a tracked, failed run 82 | test_pr_comment_for_tracked_failed_run if { 83 | result := spacelift.pull_request with input as { 84 | "account": {"name": "test-account"}, 85 | "run_updated": { 86 | "stack": {"id": "test-stack", "name": "Test Stack"}, 87 | "run": { 88 | "id": "test-run", 89 | "type": "TRACKED", 90 | "state": "FAILED", 91 | "commit": { 92 | "author": "GITHUB_USERNAME_EXAMPLE1", 93 | "hash": "abcdef123456", 94 | "url": "https://github.com/org/repo/commit/abcdef123456", 95 | }, 96 | }, 97 | }, 98 | } 99 | 100 | count(result) == 1 101 | pr_comment := result[_] 102 | pr_comment.commit == "abcdef123456" 103 | contains(pr_comment.body, "## ⚠️Spacelift Stack Failure⚠️") 104 | contains(pr_comment.body, "@GITHUB_USERNAME_EXAMPLE1") 105 | contains(pr_comment.body, "⛔️ `test-stack`") 106 | contains(pr_comment.body, "https://test-account.app.spacelift.io/stack/test-stack/run/test-run") 107 | pr_comment.deduplication_key == "test-stack" 108 | } 109 | 110 | # Test no PR comment for a non-tracked run 111 | test_no_pr_comment_for_non_tracked_run if { 112 | result := spacelift.pull_request with input as {"run_updated": {"run": { 113 | "type": "PROPOSED", 114 | "state": "FAILED", 115 | }}} 116 | 117 | count(result) == 0 118 | } 119 | 120 | # Test no PR comment for a successful run 121 | test_no_pr_comment_for_successful_run if { 122 | result := spacelift.pull_request with input as {"run_updated": {"run": { 123 | "type": "TRACKED", 124 | "state": "FINISHED", 125 | }}} 126 | 127 | count(result) == 0 128 | } 129 | -------------------------------------------------------------------------------- /examples/tests/readers-writers-admins-teams_test.rego: -------------------------------------------------------------------------------- 1 | package spacelift_test 2 | 3 | # Import the spacelift package to access its rules. 4 | import data.spacelift 5 | import future.keywords.contains 6 | import future.keywords.if 7 | import future.keywords.in 8 | 9 | test_allow_writers if { 10 | spacelift.space_write with input as {"session": {"teams": ["team4"]}} 11 | } 12 | 13 | test_allow_admins if { 14 | spacelift.space_admin with input as {"session": {"teams": ["team1"]}} 15 | } 16 | 17 | test_allow_readers if { 18 | spacelift.space_read with input as {"session": {"teams": ["team7"]}} 19 | } 20 | 21 | # Test space access for admins 22 | test_space_admin_access if { 23 | spacelift.space_admin with input as {"session": {"teams": ["team4"]}, "spaces": [{"id": "space1"}]} 24 | } 25 | 26 | # Test space access for writers 27 | test_space_write_access if { 28 | spacelift.space_write with input as {"session": {"teams": ["team1"]}, "spaces": [{"id": "space1"}]} 29 | } 30 | 31 | # Test space access for readers 32 | test_space_read_access if { 33 | spacelift.space_read with input as {"session": {"teams": ["team7"]}, "spaces": [{"id": "space1"}]} 34 | } 35 | -------------------------------------------------------------------------------- /examples/tests/track-using-labels_test.rego: -------------------------------------------------------------------------------- 1 | package spacelift 2 | 3 | # Mocked Data for Tests 4 | mock_push_affected := {"affected_files": ["tracked/file.txt", "untracked/file.txt"]} 5 | 6 | mock_push_not_affected := {"affected_files": ["not_affected/file.txt"]} 7 | 8 | mock_stack_labels_with_trackings := {"labels": ["trackeddirectories:tracked", "trackedextensions:.txt"]} 9 | 10 | # Tests 11 | 12 | # Test: track rule with different branches 13 | test_track_different_branches { 14 | not track with input as { 15 | "push": {"branch": "main"}, 16 | "stack": {"branch": "develop"}, 17 | } 18 | } 19 | 20 | # Test: propose rule with non-empty branch 21 | test_propose_non_empty_branch { 22 | propose with input as { 23 | "push": {"branch": "main"}, 24 | "stack": {}, 25 | } 26 | } 27 | 28 | # Test: propose rule with empty branch 29 | test_propose_empty_branch { 30 | not propose with input as { 31 | "push": {"branch": ""}, 32 | "stack": {}, 33 | } 34 | } 35 | 36 | # Test: affected by directory path 37 | test_affected_directory { 38 | affected with input as { 39 | "push": mock_push_affected, 40 | "stack": mock_stack_labels_with_trackings, 41 | } 42 | } 43 | 44 | # Test: affected by file extension 45 | test_affected_extension { 46 | affected with input as { 47 | "push": mock_push_affected, 48 | "stack": mock_stack_labels_with_trackings, 49 | } 50 | } 51 | 52 | # Test: not track by directory path 53 | test_not_affected_directory { 54 | not track with input as { 55 | "push": mock_push_not_affected, 56 | "stack": mock_stack_labels_with_trackings, 57 | } 58 | } 59 | 60 | # Test: not track by file extension 61 | test_not_affected_extension { 62 | not track with input as { 63 | "push": mock_push_not_affected, 64 | "stack": mock_stack_labels_with_trackings, 65 | } 66 | } 67 | 68 | # Test: track rule with not affected files 69 | test_ignore_not_affected { 70 | not track with input as { 71 | "push": mock_push_not_affected, 72 | "stack": mock_stack_labels_with_trackings, 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /examples/track-using-labels.rego: -------------------------------------------------------------------------------- 1 | package spacelift 2 | 3 | track { 4 | input.push.branch == input.stack.branch 5 | affected 6 | } 7 | 8 | propose { 9 | input.push.branch != "" 10 | } 11 | 12 | ignore { 13 | not affected 14 | } 15 | 16 | # Extract and use tracked directories and extensions from labels 17 | tracked_directories[tracked_directory] { 18 | label := input.stack.labels[_] 19 | startswith(label, "trackeddirectories:") 20 | tracked_directory := split(label, ":")[1] 21 | } 22 | 23 | tracked_extensions[tracked_extension] { 24 | label := input.stack.labels[_] 25 | startswith(label, "trackedextensions:") 26 | tracked_extension := split(label, ":")[1] 27 | } 28 | 29 | affected { 30 | some i, j 31 | path := input.push.affected_files[i] 32 | startswith(path, tracked_directories[j]) 33 | } 34 | 35 | affected { 36 | some i, j 37 | path := input.push.affected_files[i] 38 | endswith(path, tracked_extensions[j]) 39 | } 40 | 41 | sample := true 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opa-rego-test-action", 3 | "scripts": { 4 | "build": "npx ncc build src/index.ts -o dist --source-map", 5 | "test": "npx jest", 6 | "local": "npx ts-node src/index.ts", 7 | "local:resultProcessing": "npx ts-node ./src/testResultProcessing.ts" 8 | }, 9 | "author": "Masterpoint", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "@types/jest": "^29.5.12", 13 | "@types/node": "^22.1.0", 14 | "jest": "^29.7.0", 15 | "ts-jest": "^29.2.4", 16 | "typescript": "^5.5.4" 17 | }, 18 | "dependencies": { 19 | "@actions/core": "^1.10.1", 20 | "@actions/exec": "^1.1.1", 21 | "@vercel/ncc": "^0.38.1" 22 | }, 23 | "jest": { 24 | "preset": "ts-jest", 25 | "testEnvironment": "node", 26 | "moduleFileExtensions": [ 27 | "ts", 28 | "tsx", 29 | "js", 30 | "jsx", 31 | "json", 32 | "node" 33 | ], 34 | "setupFiles": [ 35 | "./.jest/setupTests.ts" 36 | ], 37 | "testPathIgnorePatterns": [ 38 | "/node_modules/", 39 | "/__tests__/mockResults.ts" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/formatResults.ts: -------------------------------------------------------------------------------- 1 | import { ProcessedTestResult, ProcessedCoverageResult } from "./interfaces"; 2 | 3 | /** 4 | * Formats the test results and coverage results into a Markdown table for GitHub comments. 5 | * @param results - The processed test results. 6 | * @param coverageResults - The processed coverage results. 7 | * @param showCoverage - Whether to include coverage information in the output. 8 | * @returns A string containing the formatted Markdown table. 9 | */ 10 | export function formatResults( 11 | results: ProcessedTestResult[], 12 | coverageResults: ProcessedCoverageResult[], 13 | showCoverage: boolean, 14 | ): string { 15 | let output = `# ${process.env.pr_comment_title || "🧪 OPA Rego Policy Test Results"}\n\n`; 16 | 17 | // Build header row 18 | let header = "| File | Status | Passed | Total |"; 19 | let separator = "|------|--------|--------|-------|"; 20 | 21 | if (showCoverage) { 22 | header += " Coverage |"; 23 | separator += "----------|"; 24 | } 25 | header += " Details |\n"; 26 | separator += "----------|\n"; 27 | output += header; 28 | output += separator; 29 | 30 | for (const result of results) { 31 | let statusEmoji, statusText; 32 | switch (result.status) { 33 | case "PASS": 34 | statusEmoji = "✅"; 35 | statusText = `${statusEmoji} PASS`; 36 | break; 37 | case "FAIL": 38 | statusEmoji = "❌"; 39 | statusText = `${statusEmoji} FAIL`; 40 | break; 41 | case "NO TESTS": 42 | statusEmoji = "⚠️"; 43 | statusText = `${statusEmoji} NO TESTS`; 44 | break; 45 | } 46 | 47 | const testFileName = result.file; 48 | const details = 49 | result.status === "NO TESTS" 50 | ? "No test file found" 51 | : result.details.join("
"); 52 | const detailsColumn = `
Show Details${details}
`; 53 | 54 | let row = `| ${testFileName} | ${statusText} | ${result.passed} | ${result.total} |`; 55 | 56 | if (showCoverage) { 57 | const coverageInfo = coverageResults.find((cr) => { 58 | const lastSlashIndex = cr.file.lastIndexOf("/"); 59 | const dotRegoIndex = cr.file.lastIndexOf(".rego"); 60 | 61 | if (lastSlashIndex === -1 || dotRegoIndex === -1) return false; 62 | 63 | const fileNameWithoutExtension = cr.file.slice( 64 | lastSlashIndex + 1, 65 | dotRegoIndex, 66 | ); 67 | return ( 68 | testFileName.includes(fileNameWithoutExtension) && 69 | !cr.file.includes(testFileName) 70 | ); 71 | }); 72 | 73 | let coverageText = "N/A"; 74 | let uncoveredLinesDetails = ""; 75 | if (coverageInfo && result.status === "PASS") { 76 | try { 77 | coverageText = `${coverageInfo.coverage.toFixed(2)}%`; 78 | if ( 79 | coverageInfo.notCoveredLines && 80 | coverageInfo.notCoveredLines !== "N/A" 81 | ) { 82 | uncoveredLinesDetails = `
Uncovered Lines${coverageInfo.notCoveredLines}
`; 83 | } 84 | coverageText += uncoveredLinesDetails; // Combine text and details 85 | } catch (error) { 86 | console.error("Error processing coverage information:", error); 87 | console.log("Coverage Info:", coverageInfo); 88 | } 89 | } 90 | row += ` ${coverageText} |`; 91 | } 92 | 93 | row += ` ${detailsColumn} |\n`; 94 | output += row; 95 | } 96 | 97 | if (process.env.indicate_source_message === "true") { 98 | output += 99 | "\n\nReport generated by [🧪 GitHub Actions for OPA Rego Test](https://github.com/masterpointio/github-action-opa-rego-test)"; 100 | } 101 | 102 | return output; 103 | } 104 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | processTestResults, 3 | processCoverageReport, 4 | } from "./testResultProcessing"; 5 | import { 6 | executeIndividualOpaTests, 7 | executeOpaTestByDirectory, 8 | } from "./opaCommands"; 9 | import { formatResults } from "./formatResults"; 10 | 11 | import { ProcessedTestResult, ProcessedCoverageResult } from "./interfaces"; 12 | 13 | import * as core from "@actions/core"; 14 | 15 | const errorString = 16 | "⛔️⛔️ An unknown error has occurred in generating the results, either from tests failing or an error running OPA or an issue with GItHub actions. View the logs for more information. ⛔️⛔️"; 17 | 18 | export async function main() { 19 | try { 20 | const test_mode = process.env.test_mode; 21 | const reportNoTestFiles = process.env.report_untested_files === "true"; 22 | const noTestFiles = process.env.no_test_files; 23 | const runCoverageReport = process.env.run_coverage_report === "true"; 24 | const path = process.env.path; 25 | const test_file_postfix = process.env.test_file_postfix; 26 | 27 | if (!path || !test_file_postfix) { 28 | throw new Error( 29 | "Both 'path' and 'test_file_postfix' environment variables must be set.", 30 | ); 31 | } 32 | 33 | let opaOutput: string = ""; 34 | let opaError: string = ""; 35 | let exitCode: number = 0; 36 | let coverageOutput: string | undefined; 37 | 38 | if (test_mode === "directory") { 39 | ({ 40 | output: opaOutput, 41 | error: opaError, 42 | exitCode: exitCode, 43 | coverageOutput: coverageOutput, 44 | } = await executeOpaTestByDirectory(path, true)); 45 | } else { 46 | ({ 47 | output: opaOutput, 48 | error: opaError, 49 | exitCode: exitCode, 50 | coverageOutput: coverageOutput, 51 | } = await executeIndividualOpaTests(path, test_file_postfix, true)); 52 | } 53 | 54 | let parsedResults = processTestResults(JSON.parse(opaOutput)); 55 | 56 | let coverageResults: ProcessedCoverageResult[] = []; 57 | if (runCoverageReport) { 58 | if (coverageOutput) { 59 | coverageResults = processCoverageReport(JSON.parse(coverageOutput)); 60 | } 61 | } 62 | 63 | // 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. 64 | if (noTestFiles && reportNoTestFiles) { 65 | const noTestFileResults: ProcessedTestResult[] = noTestFiles 66 | .split("\n") 67 | .map((file) => ({ 68 | file: file.trim(), 69 | status: "NO TESTS", 70 | passed: 0, 71 | total: 0, 72 | details: [], 73 | })); 74 | parsedResults = [...parsedResults, ...noTestFileResults]; 75 | } 76 | 77 | let formattedOutput = formatResults( 78 | parsedResults, 79 | coverageResults, 80 | runCoverageReport, 81 | ); 82 | 83 | if (formattedOutput === "") { 84 | formattedOutput = errorString; 85 | } 86 | 87 | // This is the output that will be used in the GitHub Pull Request comment. 88 | core.setOutput("parsed_results", formattedOutput); 89 | 90 | const testsFailed = parsedResults.some( 91 | (result) => result.status === "FAIL", 92 | ); 93 | core.setOutput("tests_failed", testsFailed.toString()); 94 | 95 | if (testsFailed) { 96 | core.setFailed(`One or more OPA tests failed: ${opaError}`); 97 | } 98 | } catch (error) { 99 | if (error instanceof Error) { 100 | core.setFailed(`Action failed with error: ${error.message}`); 101 | } else { 102 | core.setFailed("Action failed with an unknown error"); 103 | } 104 | } 105 | } 106 | 107 | main(); 108 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface ProcessedTestResult { 2 | file: string; 3 | status: "PASS" | "FAIL" | "NO TESTS"; 4 | passed: number; 5 | total: number; 6 | details: string[]; // Array of either "✅ test_name" or "❌ test_name" for the test within the file 7 | } 8 | 9 | export interface ProcessedCoverageResult { 10 | file: string; 11 | coverage: number; 12 | notCoveredLines: string; 13 | } 14 | 15 | // This is what is returned from the OPA test command with `--format=json` and `--coverage` 16 | export interface OpaRawJsonCoverageReport { 17 | files: { 18 | [filePath: string]: { 19 | // Array of covered code sections 20 | covered?: Array<{ 21 | start: { 22 | row: number; 23 | }; 24 | end: { 25 | row: number; 26 | }; 27 | }>; 28 | 29 | // Array of code sections that are not covered by tests 30 | not_covered?: Array<{ 31 | start: { 32 | row: number; 33 | }; 34 | end: { 35 | row: number; 36 | }; 37 | }>; 38 | 39 | covered_lines?: number; 40 | 41 | // Total number of lines not covered by tests (only present if there are uncovered lines) 42 | not_covered_lines?: number; 43 | 44 | // Coverage percentage (0-100) 45 | coverage: number; 46 | }; 47 | }; 48 | } 49 | 50 | // Interface for individual OPA test result - this is what is returned from the OPA test command with --format=json 51 | export interface OpaRawJsonTestResult { 52 | location: { 53 | file: string; 54 | row: number; 55 | col: number; 56 | }; 57 | package: string; 58 | name: string; 59 | fail?: boolean; 60 | duration: number; 61 | } 62 | -------------------------------------------------------------------------------- /src/opaCommands.ts: -------------------------------------------------------------------------------- 1 | import { OpaRawJsonTestResult } from "./interfaces"; 2 | import * as exec from "@actions/exec"; 3 | import path from "path"; 4 | 5 | const opaV0CompatibleFlag = "--v0-compatible"; // https://www.openpolicyagent.org/docs/latest/v0-compatibility/ 6 | 7 | export async function executeOpaTestByDirectory( 8 | path: string, 9 | runCoverageReport: boolean = false, 10 | ): Promise<{ 11 | output: string; 12 | error: string; 13 | exitCode: number; 14 | coverageOutput?: string; 15 | coverageExitCode?: number; 16 | }> { 17 | let opaOutput = ""; 18 | let opaError = ""; 19 | let opaCoverageOutput = ""; 20 | let exitCode = 0; 21 | let coverageExitCode; 22 | 23 | const options: exec.ExecOptions = { 24 | listeners: { 25 | stdout: (data: Buffer) => { 26 | opaOutput += data.toString(); 27 | }, 28 | stderr: (data: Buffer) => { 29 | opaError += data.toString(); 30 | }, 31 | }, 32 | ignoreReturnCode: true, 33 | }; 34 | 35 | exitCode = await exec.exec( 36 | "opa", 37 | ["test", path, "--format=json", opaV0CompatibleFlag], 38 | options, 39 | ); 40 | 41 | if (runCoverageReport) { 42 | const coverageOptions: exec.ExecOptions = { 43 | listeners: { 44 | stdout: (data: Buffer) => { 45 | opaCoverageOutput += data.toString(); 46 | }, 47 | stderr: (data: Buffer) => { 48 | opaError += `\nCoverage: ${data.toString()}`; 49 | }, 50 | }, 51 | ignoreReturnCode: true, 52 | }; 53 | 54 | coverageExitCode = await exec.exec( 55 | "opa", 56 | ["test", path, "--format=json", "--coverage", opaV0CompatibleFlag], 57 | coverageOptions, 58 | ); 59 | } else { 60 | console.log( 61 | "Coverage reporting skipped due to runCoverageReport flag set to false", 62 | ); 63 | } 64 | 65 | console.log("OPA test commands completed"); 66 | 67 | return { 68 | output: opaOutput, 69 | error: opaError, 70 | exitCode: exitCode, 71 | ...(runCoverageReport && { 72 | coverageOutput: opaCoverageOutput, 73 | coverageExitCode: coverageExitCode, 74 | }), 75 | }; 76 | } 77 | 78 | /** 79 | * Run OPA tests on all files matching the given test file postfix in the specified base path. 80 | * @param basePath - The base path to search for test files. 81 | * @param testFilePostfix - The postfix of the test files to look for (e.g., "_test"). 82 | * @param runCoverageReport - Whether to run coverage report (default: false). 83 | * @returns An object containing the test results, error messages, and exit codes. 84 | */ 85 | export async function executeIndividualOpaTests( 86 | basePath: string, 87 | testFilePostfix: string, 88 | runCoverageReport = false, 89 | ): Promise<{ 90 | output: string; 91 | error: string; 92 | exitCode: number; 93 | coverageOutput?: string; 94 | coverageExitCode?: number; 95 | }> { 96 | const allTestResults: OpaRawJsonTestResult[] = []; 97 | let opaError = ""; 98 | let exitCode = 0; 99 | 100 | const coverageFiles: Record = {}; 101 | let coverageExitCode = 0; 102 | 103 | // ---------- locate test files ---------- 104 | let findStdout = ""; 105 | let findStderr = ""; 106 | await exec.exec( 107 | "find", 108 | [basePath, "-type", "f", "-name", `*${testFilePostfix}.rego`], 109 | { 110 | listeners: { 111 | stdout: (b: Buffer) => (findStdout += b.toString()), 112 | stderr: (b: Buffer) => (findStderr += b.toString()), 113 | }, 114 | }, 115 | ); 116 | 117 | if (findStderr) { 118 | opaError += findStderr + "\n"; 119 | exitCode = 1; 120 | } 121 | 122 | const testFiles = findStdout.trim().split("\n").filter(Boolean); 123 | 124 | for (const testFile of testFiles) { 125 | const base = path.basename(testFile, `${testFilePostfix}.rego`); 126 | const dir = path.dirname(testFile); 127 | 128 | // locate impl file 129 | let implOut = ""; 130 | await exec.exec( 131 | "find", 132 | [ 133 | dir, 134 | `${dir}/..`, 135 | "-maxdepth", 136 | "1", 137 | "-type", 138 | "f", 139 | "-name", 140 | `${base}.rego`, 141 | ], 142 | { 143 | listeners: { stdout: (b: Buffer) => (implOut += b.toString()) }, 144 | }, 145 | ); 146 | const implFile = implOut.trim().split("\n").find(Boolean); 147 | if (!implFile) { 148 | const msg = `Error: Implementation file not found for test: ${testFile}`; 149 | opaError += msg + "\n"; 150 | exitCode = 1; 151 | coverageExitCode = 1; 152 | continue; 153 | } 154 | 155 | // -------- Running OPA test -------- 156 | let testOutput = ""; 157 | let testErrMsg = ""; 158 | const testExitCode = await exec.exec( 159 | "opa", 160 | ["test", testFile, implFile, "--format=json", opaV0CompatibleFlag], 161 | { 162 | listeners: { 163 | stdout: (b: Buffer) => (testOutput += b.toString()), 164 | stderr: (b: Buffer) => (testErrMsg += b.toString()), 165 | }, 166 | ignoreReturnCode: true, 167 | }, 168 | ); 169 | 170 | if (testExitCode) exitCode = testExitCode; 171 | if (testErrMsg) opaError += testErrMsg; 172 | 173 | try { 174 | const parsed = JSON.parse(testOutput); 175 | if (Array.isArray(parsed)) allTestResults.push(...parsed); 176 | } catch (e) { 177 | opaError += `Error parsing test results for ${testFile}: ${e}\n`; 178 | exitCode = 1; 179 | } 180 | 181 | // -------- coverage (optional) -------- 182 | if (runCoverageReport) { 183 | let covOut = ""; 184 | let covErr = ""; 185 | const covExit = await exec.exec( 186 | "opa", 187 | [ 188 | "test", 189 | testFile, 190 | implFile, 191 | "--coverage", 192 | "--format=json", 193 | opaV0CompatibleFlag, 194 | ], 195 | { 196 | listeners: { 197 | stdout: (b: Buffer) => (covOut += b.toString()), 198 | stderr: (b: Buffer) => (covErr += b.toString()), 199 | }, 200 | ignoreReturnCode: true, 201 | }, 202 | ); 203 | coverageExitCode = Math.max(coverageExitCode, covExit); 204 | if (covErr) opaError += `Coverage error for ${testFile}: ${covErr}`; 205 | 206 | try { 207 | const covJson = JSON.parse(covOut); 208 | if (covJson?.files) { 209 | Object.assign(coverageFiles, covJson.files); 210 | } 211 | } catch (e) { 212 | opaError += `Error parsing coverage for ${testFile}: ${e}\n`; 213 | coverageExitCode = 1; 214 | } 215 | } 216 | } 217 | 218 | return { 219 | output: JSON.stringify(allTestResults), 220 | error: opaError, 221 | exitCode, 222 | ...(runCoverageReport && { 223 | coverageOutput: JSON.stringify({ files: coverageFiles }), 224 | coverageExitCode, 225 | }), 226 | }; 227 | } 228 | -------------------------------------------------------------------------------- /src/testResultProcessing.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ProcessedTestResult, 3 | OpaRawJsonTestResult, 4 | OpaRawJsonCoverageReport, 5 | ProcessedCoverageResult, 6 | } from "./interfaces"; 7 | 8 | /** 9 | * Processes the raw JSON test results from OPA and formats them into a structure ready to be formatted into a GitHub Pull Request comment. 10 | * @param opaRawJsonTestResult - The raw JSON test results from OPA, obtained from `opa test --format=json`. This is done in the `opaCommands.ts` file. 11 | * @returns An array of processed test results. See the interface for structure. 12 | */ 13 | export function processTestResults( 14 | opaRawJsonTestResult: OpaRawJsonTestResult[], 15 | ): ProcessedTestResult[] { 16 | // Group by file 17 | const fileMap = new Map(); 18 | 19 | // Group tests by file 20 | opaRawJsonTestResult.forEach((result) => { 21 | const file = result.location.file; 22 | if (!fileMap.has(file)) { 23 | fileMap.set(file, []); 24 | } 25 | fileMap.get(file)!.push(result); 26 | }); 27 | 28 | // Process each file's results 29 | const testResults: ProcessedTestResult[] = []; 30 | 31 | fileMap.forEach((tests, file) => { 32 | const result: ProcessedTestResult = { 33 | file, 34 | status: "PASS", 35 | passed: 0, 36 | total: tests.length, 37 | details: [], 38 | }; 39 | 40 | // Count passed tests and collect details 41 | tests.forEach((test) => { 42 | const passed = !test.fail; 43 | 44 | if (passed) { 45 | result.passed++; 46 | result.details.push(`✅ ${test.name}`); 47 | } else { 48 | // If any test fails, the file status is FAIL 49 | result.status = "FAIL"; 50 | result.details.push(`❌ ${test.name}`); 51 | } 52 | }); 53 | 54 | testResults.push(result); 55 | }); 56 | 57 | return testResults; 58 | } 59 | 60 | /** 61 | * Processes the raw JSON coverage report from OPA and formats it into a structure ready to be formatted into a GitHub Pull Request comment. 62 | * @param opaRawJsonCoverageReport - The raw JSON coverage report from OPA, obtained from `opa test --format=json --coverage`. This is done in the `opaCommands.ts` file. 63 | * @returns An array of processed coverage results. See the interface for structure. 64 | */ 65 | export function processCoverageReport( 66 | opaRawJsonCoverageReport: OpaRawJsonCoverageReport, 67 | ): ProcessedCoverageResult[] { 68 | const coverageResults: ProcessedCoverageResult[] = []; 69 | 70 | // Iterate through each file in the report 71 | for (const [filePath, fileData] of Object.entries( 72 | opaRawJsonCoverageReport.files, 73 | )) { 74 | // Skip if there are no uncovered lines (100% coverage) 75 | if (!fileData.not_covered || fileData.not_covered.length === 0) { 76 | coverageResults.push({ 77 | file: filePath, 78 | coverage: fileData.coverage, 79 | notCoveredLines: "", // No uncovered lines 80 | }); 81 | continue; 82 | } 83 | 84 | // Process not_covered sections to create the formatted string 85 | const notCoveredRanges: string[] = []; 86 | 87 | for (const section of fileData.not_covered) { 88 | const startRow = section.start.row; 89 | const endRow = section.end.row; 90 | 91 | if (startRow === endRow) { 92 | // Single line 93 | notCoveredRanges.push(startRow.toString()); 94 | } else { 95 | // Range of lines, e.g. "10-12" 96 | notCoveredRanges.push(`${startRow}-${endRow}`); 97 | } 98 | } 99 | 100 | // Sort numerically 101 | notCoveredRanges.sort((a, b) => { 102 | // Extract the first number from each range for comparison 103 | const aStart = parseInt(a.split("-")[0]); 104 | const bStart = parseInt(b.split("-")[0]); 105 | return aStart - bStart; 106 | }); 107 | 108 | coverageResults.push({ 109 | file: filePath, 110 | coverage: fileData.coverage, 111 | notCoveredLines: notCoveredRanges.join(", "), 112 | }); 113 | } 114 | 115 | return coverageResults; 116 | } 117 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 4 | "lib": [ 5 | "es6" 6 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 7 | "module": "commonjs" /* Specify what module code is generated. */, 8 | "rootDir": "." /* Specify the root folder within your source files. */, 9 | "resolveJsonModule": true /* Enable importing .json files. */, 10 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */, 11 | "outDir": "build" /* Specify an output folder for all emitted files. */, 12 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 13 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 14 | "strict": true /* Enable all strict type-checking options. */, 15 | "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */, 16 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 17 | } 18 | } 19 | --------------------------------------------------------------------------------