feat: scaffold deployment-policies monorepo
ci / verify (push) Failing after 10m57s
ci / publish (push) Skipped

OPA/Rego deployment gateway policies: 5 rules (valid-environment,
require-approvals, no-self-approval, block-weekends, freeze-window),
a deploy-gate combining them, JSON schemas, scenario-driven tests,
Gitea Actions CI (verify + publish to generic registry), changesets
versioning, Makefile and scripts.
This commit is contained in:
jdevega
2026-09-15 19:05:03 +02:00
commit 59cacfdfab
48 changed files with 1703 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# Changesets
This repository uses [Changesets](https://github.com/changesets/changesets) to version the OPA
policy packages. Every `policies/rules/*` and `policies/gates/*` package is an independent npm
workspace; changesets records which packages changed so versions can be bumped consistently.
## Adding a changeset
Run from the repository root:
```sh
make changeset
```
It opens an interactive prompt: select the packages you changed, the bump type (`patch` for
fixes/behaviour tweaks, `minor` for new rules, `major` for breaking input/data contract changes)
and a summary. This writes a file under `.changeset/`.
Commit the changeset file together with your policy code changes.
## Releasing
When changesets are present on `main`, run:
```sh
make version
```
This applies all changesets, rewrites the `version` field of affected `package.json`s and
regenerates `CHANGELOG.md` files (formatted with `oxfmt`). Commit the resulting bump.
Bundles are built from `package.json#version`, so the registry upload uses the new version.
## Gitea Actions
CI verifies (format, lint, schema check, tests) and builds bundles on every push/PR. On `main`
the `publish` job uploads the bundles to the Gitea generic package registry using the
`GITEA_TOKEN` repository secret (requires `write:package` scope).
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [],
"format": "oxfmt",
"privatePackages": {
"version": true,
"tag": false
}
}
+59
View File
@@ -0,0 +1,59 @@
name: ci
on:
push:
branches:
- main
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install npm deps
run: npm ci
- name: Install OPA
run: ./scripts/install-opa.sh
- name: Check, lint and test
run: ./scripts/check.sh
- name: Build bundles
run: ./scripts/build.sh
publish:
if: github.ref == 'refs/heads/main'
needs: verify
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install npm deps
run: npm ci
- name: Install OPA
run: ./scripts/install-opa.sh
- name: Build bundles
run: ./scripts/build.sh
- name: Publish bundles to Gitea package registry
env:
GITEA_URL: https://gitea.devegamoreno.com
GITEA_OWNER: jdevega
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: ./scripts/publish.sh
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
tools/opa
*.log
.DS_Store
+52
View File
@@ -0,0 +1,52 @@
SHELL := /bin/bash
SCRIPT_DIR := scripts
OPA := tools/opa
.PHONY: help install check fmt lint test build publish changeset version
help:
@echo "Targets:"
@echo " install Install pinned OPA binary into tools/"
@echo " check Rego fmt + regal lint (if present) + schema-check + tests"
@echo " fmt Format all .rego files with opa fmt"
@echo " lint Run regal lint (requires regal on PATH)"
@echo " test Run opa test for every rule and gate"
@echo " build Build OPA bundles into dist/"
@echo " publish Publish bundles to Gitea generic registry (needs GITEA_TOKEN)"
@echo " changeset Create a changeset for a new version"
@echo " version Apply changesets to bump package versions"
install:
$(SCRIPT_DIR)/install-opa.sh
check:
$(SCRIPT_DIR)/check.sh
fmt:
$(OPA) fmt -w policies
lint:
[ -z "$$(command -v regal)" ] || regal lint policies --format github
test:
@for d in policies/rules/*/; do \
name="$$(basename "$$d")"; \
echo "== test $$name"; \
$(OPA) test "$$d/policy.rego" "$$d/policy_test.rego" "$$d/scenarios.json" || exit 1; \
done
@rules=""; \
for d in policies/rules/*/; do rules="$$rules $$d/policy.rego"; done; \
echo "== test deploy-gate"; \
$(OPA) test policies/gates/deploy-gate/gate_test.rego policies/gates/deploy-gate/scenarios.json $$rules
build:
$(SCRIPT_DIR)/build.sh
publish:
$(SCRIPT_DIR)/publish.sh
changeset:
npx changeset
version:
npx changeset version
+172
View File
@@ -0,0 +1,172 @@
# deployment-policies
Small, composable OPA/Rego policies that act as **deployment gateways**. Each rule decides one
narrow question about a deployment request (environment validity, approvals, weekends, freeze
windows…), and gates bundle the rules you want to enforce together.
- **Language:** Rego (OPA 1.x, `rego.v1` syntax)
- **Hosting:** Gitea (`gitea.devegamoreno.com`), repo `jdevega/deployment-policies`
- **CI/CD:** Gitea Actions (verify + build on every push/PR, publish on `main`)
- **Distribution:** Gitea generic package registry (rules and gates as OPA bundles)
- **Versioning:** actions/Changesets over npm workspaces
## Repository layout
```
policies/
rules/
<rule>/ # one deployable policy = one bundle
policy.rego # module in package rules.<name>; exposes allow + violations
policy_test.rego # tests driven by scenarios.json
scenarios.json # input/data/expectation fixtures (loaded at data.scenarios)
schema/ # JSON Schemas for input and data (opa check --schema)
package.json # version + identity
gates/
<gate>/ # one deployable gate = bundle of the rules it depends on
gate_test.rego # integration tests over the combined rules
scenarios.json
schema/
package.json # lists rule packages as dependencies
scripts/ # install-opa, check, build, publish
.gitea/workflows/ci.yml # Gitea Actions pipeline
Makefile # convenience targets
```
## Requirements
- Node.js ≥ 22.11 (workspaces + changesets v3)
- `make`, `curl` (or `wget`)
- OPA binary installed via `make install` (pinned release, no Docker)
- `regal` on `PATH` for linting (optional, skipped if absent)
## Quick start
```sh
make install # install OPA v1.20.2 to tools/opa
npm ci # install changesets + oxfmt
make check # format check + regal lint + schema check + tests
make build # build rule and gate bundles into dist/
```
## Rego conventions
Every policy module targets **separate evaluation** and follows the same contract:
- `package rules.<name>` — one package per rule, no cross-file imports between rules.
- `allow` (boolean, `default false`) — this rule alone permits the deployment.
- `violations` (set of `{"code", "message"}`) — the reasons for denial.
- Input is read from `input.deployment` / `input.approvals`.
- Environment configuration and freeze windows are read from `data.deploygate.*`
(`config.<env>.min_approvals`, `config.<env>.block_weekends`, `freeze_windows[]`).
Combining policies is done **at runtime** by the consumer: load one or more bundles, evaluate
each rule under `data.rules.<name>` separately, and fold the `violations`/`allow` results
together. Gates simply ship several rules in one bundle for convenience.
## Input contract
| Field | Type | Used by |
| ----------------------------- | -------- | ---------------------------------- |
| `input.deployment.environment`| string | all rules |
| `input.deployment.service` | string | (informational) |
| `input.deployment.version` | string | (informational) |
| `input.deployment.requested_by`| string | no-self-approval |
| `input.deployment.created_at` | RFC3339 | block-weekends, freeze-window |
| `input.approvals[].by` | string | no-self-approval |
| `input.approvals[].active` | boolean | require-approvals, no-self-approval|
| `input.approvals[].at` | RFC3339 | (informational) |
## Data contract
| Field | Type | Read by |
| ---------------------------------------- | ------ | ------------------------------- |
| `data.deploygate.config.<env>.min_approvals` | int | require-approvals |
| `data.deploygate.config.<env>.block_weekends`| bool | block-weekends |
| `data.deploygate.freeze_windows[].environment` | string | freeze-window |
| `data.deploygate.freeze_windows[].start` | RFC3339 | freeze-window |
| `data.deploygate.freeze_windows[].end` | RFC3339 | freeze-window |
Config lives in the data document (not in the bundle), so rules never embed environment-specific
settings. Bundles scope their roots to `rules`, letting callers merge `data.deploygate.*`.
## Available rules
| Rule | Package | Denies when |
| ----------------- | ------------------------ | ----------- |
| `valid-environment`| `rules.valid_environment`| environment is not in `data.deploygate.config` |
| `require-approvals`| `rules.require_approvals`| fewer active approvals than `min_approvals` |
| `no-self-approval` | `rules.no_self_approval` | an active approver is the requestor |
| `block-weekends` | `rules.block_weekends` | `created_at` is Sat/Sun and env has `block_weekends` |
| `freeze-window` | `rules.freeze_window` | deploy falls inside a matching freeze window |
## Available gates
| Gate | Package | Includes |
| ------------- | -------------------- | -------- |
| `deploy-gate` | `gate.deploy_gate_test` | all 5 rules |
A gate's `package.json` lists its rules under `dependencies`; changesets bumps the gate when any
of its rules change, and `scripts/build.sh` bundles exactly those rule modules into the gate.
## Testing
Each rule carries `scenarios.json` with `{ scenarios: { <name>: { input, data, expect } } }`.
`opa test` loads it at `data.scenarios` (top-level key, not filename). Tests assert both the
expected `violations` codes and `allow`. See `scripts/check.sh`; run with `make test`.
## Building and publishing
```sh
make build # dist/rules/<name>/<version>/bundle.tar.gz and dist/gates/<name>/<version>/bundle.tar.gz
make publish # upload all bundles to the Gitea generic registry
```
`publish.sh` needs:
- `GITEA_TOKEN` (API token with `write:package`)
- `GITEA_URL` (default `https://gitea.devegamoreno.com`)
- `GITEA_OWNER` (default `jdevega`)
Artifacts land at:
```
/api/packages/{owner}/generic/rule-<rule>/<version>/bundle.tar.gz
/api/packages/{owner}/generic/gate-<gate>/<version>/bundle.tar.gz
```
Bundles are built fresh in CI (`publish` job on `main`) and uploaded with `curl`.
## Versioning
On a new commit, run `make changeset`, select the changed packages and bump types, and commit the
generated changeset. Later run `make version` to apply them. See
[`.changeset/README.md`](.changeset/README.md).
## CI
`.gitea/workflows/ci.yml`:
1. `verify``npm ci`, install OPA, `scripts/check.sh`, `scripts/build.sh` (every push/PR).
2. `publish` — on `main` only, rebuilds and uploads bundles with `GITEA_TOKEN`.
Create the `GITEA_TOKEN` repository secret on Gitea with at least `write:package` scope.
## Evaluating a bundle
```sh
opa eval \
--bundle dist/rules/valid-environment/0.0.0/bundle.tar.gz \
--data deploygate.json \
--input request.json \
'data.rules.valid_environment'
```
Or load a gate bundle and fuse the rule results yourself:
```sh
opa eval --bundle dist/gates/deploy-gate/<v>/bundle.tar.gz --data deploygate.json \
--input request.json \
'{codes: {c | some r in [data.rules.valid_environment, data.rules.require_approvals]; some v in r.violations; c := v.code}}'
```
See the input/data contracts above for `deploygate.json` and `request.json` shapes.
+65
View File
@@ -0,0 +1,65 @@
# Tech Stack
## Overview
| Layer | Technology | Version | Purpose |
| --------------- | ---------- | ------- | ------- |
| Policy language | Rego | OPA 1.x (`rego.v1`) | Authoring deployment-gateway policy rules and gates |
| Policy engine | OPA | `v1.20.2` (pinned) | Evaluating, testing, type-checking and bundling the policies |
| Policy linter | regal | `v0.42.0` | Static linting of Rego best practices (optional in CI) |
| Bundles | OPA bundle format | — | Distributable `.tar.gz` bundles (rule modules at `data.rules.*`) |
| Schemas | JSON Schema (draft 2020-12) | — | Static validation of `input` and `data` via `opa check --schema` |
| Versioning | Changesets | `@changesets/cli@3.0.3` | Tracking per-policy version bumps across npm workspaces |
| Changelog formatting | oxfmt | `^0.68.0` | Formatting generated changelogs/changesets via `format: "oxfmt"` |
| Package management | npm workspaces | npm 11 / Node 24 | Monorepo layout (`policies/rules/*`, `policies/gates/*`) |
| CI/CD | Gitea Actions | Gitea 1.27.0 | Verify, test, build and publish on `main` |
| Package registry| Gitea generic registry | — | Storing published rule/gate bundles |
| Build tooling | GNU make + bash | — | `Makefile` targets wrapping the scripts |
| Hosting | Gitea self-hosted | `1.27.0` | Source control, CI runners, package registry (gitea.devegamoreno.com) |
## Justification
- **OPA + Rego**: standard, policy-as-code engine for authorization/gating; bundles are the
portable distribution unit; binary CLI avoids container runtime requirements.
- **Pinned OPA `v1.20.2`**: reproducible engine version locally and in CI via `scripts/install-opa.sh`
(no Docker), so tests and `opa build` see identical behaviour everywhere.
- **`rego.v1`**: canonical OPA 1.x syntax (import of `rego.v1`), future-proof and unambiguous.
- **Per-rule JSON Schemas**: `opa check --strict --schema` catches input/data type mistakes at
authoring time instead of runtime.
- **Separate evaluation / small rules**: each rule is an independent package+bundle with a small,
testable contract; consumers combine the results at runtime, enabling reuse across gates.
- **Changesets over npm workspaces**: each rule/gate is an npm package solely for versioning;
changesets links repository-level changes to published registry versions. `oxfmt` is used only
to format changelog markdown (there is no `prettier-plugin-rego` on npm), while Rego formatting
is enforced by `opa fmt` in CI.
- **Gitea generic registry**: stores arbitrary files (`.tar.gz` bundles) under
`/api/packages/{owner}/generic/{package}/{version}/{file}` without adding an external registry
dependency.
- **npm workspaces** (over pnpm/yarn): zero extra tooling; changesets v3 requires Node ≥ 22.11 and
npm ≥ 10.9, both met.
## Tools not used (and why)
- **Docker/containers**: excluded by design; OPA ships a static binary, so containers add
unnecessary runtime complexity for tests, checks and bundle builds.
- **prettier-plugin-rego**: does not exist on npm (verified 404); Rego formatting is
instead owned by `opa fmt`.
- **pnpm/yarn**: changesets supports them, but npm workspaces suffice since these packages are
private versioning shims, not published JS packages.
## Integration points
- **Gitea Actions** reads `secrets.GITEA_TOKEN` (scope `write:package`) and
`GITEA_OWNER`/`GITEA_URL`; publish job runs only on `main`.
- **`scripts/publish.sh`** uploads via `curl` to the generic registry; package names are
prefixed `rule-*` and `gate-*`.
- **Consumer contract**: bundles scope roots to `rules` so operators can supply
`data.deploygate.*` configuration separately and still have static schema checks apply.
## Environment (local dev machine)
- macOS (darwin/arm64), zsh
- Node.js `v24.18.0`, npm `11.16.0`
- git `2.50.1`, GNU make
- `tools/opa` (OPA `v1.20.2` binary committed to the repo working tree via install script, not git)
- regal `v0.42.0` optional on `PATH`
+14
View File
@@ -0,0 +1,14 @@
{
"name": "deployment-policies",
"private": true,
"version": "0.0.0",
"description": "Small composable OPA/Rego deployment gateway policies, tested, linted and published to the Gitea generic package registry.",
"workspaces": [
"policies/rules/*",
"policies/gates/*"
],
"devDependencies": {
"@changesets/cli": "^3.0.3",
"oxfmt": "^0.68.0"
}
}
+49
View File
@@ -0,0 +1,49 @@
# METADATA
# schemas:
# - input: schema["input"]
# - data: schema["data"]
package gate.deploy_gate_test
import data.rules.block_weekends as block_weekends
import data.rules.freeze_window as freeze_window
import data.rules.no_self_approval as no_self_approval
import data.rules.require_approvals as require_approvals
import data.rules.valid_environment as valid_environment
import rego.v1
violations_for[scenario_name] contains code if {
some scenario_name, scenario in data.scenarios
some v in valid_environment.violations with input as scenario.input with data.deploygate as scenario.data
code := v.code
}
violations_for[scenario_name] contains code if {
some scenario_name, scenario in data.scenarios
some v in require_approvals.violations with input as scenario.input with data.deploygate as scenario.data
code := v.code
}
violations_for[scenario_name] contains code if {
some scenario_name, scenario in data.scenarios
some v in no_self_approval.violations with input as scenario.input with data.deploygate as scenario.data
code := v.code
}
violations_for[scenario_name] contains code if {
some scenario_name, scenario in data.scenarios
some v in block_weekends.violations with input as scenario.input with data.deploygate as scenario.data
code := v.code
}
violations_for[scenario_name] contains code if {
some scenario_name, scenario in data.scenarios
some v in freeze_window.violations with input as scenario.input with data.deploygate as scenario.data
code := v.code
}
test_scenario[scenario_name] if {
some scenario_name, scenario in data.scenarios
expected := {code | code := scenario.expect.violations[_]}
{code | code := violations_for[scenario_name][_]} == expected
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "deploy-gate",
"version": "0.0.0",
"description": "Combined deployment gateway: validates the environment, approval counts, self-approval, weekends and freeze windows.",
"private": true,
"dependencies": {
"valid-environment": "0.0.0",
"require-approvals": "0.0.0",
"no-self-approval": "0.0.0",
"block-weekends": "0.0.0",
"freeze-window": "0.0.0"
}
}
+107
View File
@@ -0,0 +1,107 @@
{
"scenarios": {
"all_pass": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-15T10:00:00Z"
},
"approvals": [
{"by": "bob", "at": "2026-09-15T09:00:00Z", "active": true},
{"by": "carol", "at": "2026-09-15T09:30:00Z", "active": true}
]
},
"data": {
"config": {
"production": {"min_approvals": 2, "block_weekends": true}
},
"freeze_windows": [
{"environment": "production", "start": "2026-12-20T00:00:00Z", "end": "2026-12-31T23:59:59Z"}
]
},
"expect": {
"allow": true,
"violations": []
}
},
"weekend_plus_no_approvals": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-12T10:00:00Z"
},
"approvals": [
{"by": "bob", "at": "2026-09-12T09:00:00Z", "active": true}
]
},
"data": {
"config": {
"production": {"min_approvals": 2, "block_weekends": true}
},
"freeze_windows": []
},
"expect": {
"allow": false,
"violations": ["weekend_deploy", "insufficient_approvals"]
}
},
"freeze_window_blocks": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-12-24T10:00:00Z"
},
"approvals": [
{"by": "bob", "at": "2026-12-24T09:00:00Z", "active": true},
{"by": "carol", "at": "2026-12-24T09:30:00Z", "active": true}
]
},
"data": {
"config": {
"production": {"min_approvals": 2, "block_weekends": false}
},
"freeze_windows": [
{"environment": "production", "start": "2026-12-20T00:00:00Z", "end": "2026-12-31T23:59:59Z"}
]
},
"expect": {
"allow": false,
"violations": ["deploy_in_freeze"]
}
},
"self_approval": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-15T10:00:00Z"
},
"approvals": [
{"by": "alice", "at": "2026-09-15T09:00:00Z", "active": true},
{"by": "carol", "at": "2026-09-15T09:30:00Z", "active": true}
]
},
"data": {
"config": {
"production": {"min_approvals": 2, "block_weekends": true}
},
"freeze_windows": []
},
"expect": {
"allow": false,
"violations": ["self_approval"]
}
}
}
}
@@ -0,0 +1,45 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"deploygate": {
"type": "object",
"additionalProperties": true,
"properties": {
"config": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": true
}
},
"freeze_windows": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true,
"required": ["environment", "start", "end"],
"properties": {
"environment": {"type": "string"},
"start": {"type": "string", "format": "date-time"},
"end": {"type": "string", "format": "date-time"}
}
}
}
}
},
"rules": {
"type": "object",
"additionalProperties": true
},
"gate": {
"type": "object",
"additionalProperties": true
},
"scenarios": {
"type": "object",
"additionalProperties": true
}
}
}
@@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"deployment": {
"type": "object",
"additionalProperties": true,
"required": ["environment", "service", "requested_by", "created_at"],
"properties": {
"environment": {"type": "string"},
"service": {"type": "string"},
"requested_by": {"type": "string"},
"created_at": {"type": "string", "format": "date-time"}
}
},
"approvals": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true,
"required": ["by", "active"],
"properties": {
"by": {"type": "string"},
"active": {"type": "boolean"},
"at": {"type": "string", "format": "date-time"}
}
}
}
}
}
@@ -0,0 +1,6 @@
{
"name": "block-weekends",
"version": "0.0.0",
"description": "Denies deployments that fall on a weekend when the environment has block_weekends enabled.",
"private": true
}
+21
View File
@@ -0,0 +1,21 @@
# METADATA
# schemas:
# - input: schema["input"]
# - data: schema["data"]
package rules.block_weekends
import rego.v1
default allow := false
allow if {
count(violations) == 0
}
violations contains {"code": "weekend_deploy", "message": sprintf("deployments blocked on %s for environment %q", [weekday, input.deployment.environment])} if {
input.deployment
weekday := time.weekday(time.parse_rfc3339_ns(input.deployment.created_at))
weekday in {"Saturday", "Sunday"}
data.deploygate.config[input.deployment.environment].block_weekends
}
@@ -0,0 +1,12 @@
package rules.block_weekends_test
import data.rules.block_weekends as rule
import rego.v1
test_scenarios[scenario_name] if {
some scenario_name, scenario in data.scenarios
actual := {code | code := rule.violations[_].code} with input as scenario.input with data.deploygate as scenario.data
expected := {code | code := scenario.expect.violations[_]}
actual == expected
rule.allow == scenario.expect.allow with input as scenario.input with data.deploygate as scenario.data
}
@@ -0,0 +1,107 @@
{
"scenarios": {
"weekday_ok": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-15T10:00:00Z"
},
"approvals": []
},
"data": {
"config": {
"production": {"block_weekends": true}
}
},
"expect": {
"allow": true,
"violations": []
}
},
"saturday_blocked": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-12T10:00:00Z"
},
"approvals": []
},
"data": {
"config": {
"production": {"block_weekends": true}
}
},
"expect": {
"allow": false,
"violations": ["weekend_deploy"]
}
},
"sunday_blocked": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-13T10:00:00Z"
},
"approvals": []
},
"data": {
"config": {
"production": {"block_weekends": true}
}
},
"expect": {
"allow": false,
"violations": ["weekend_deploy"]
}
},
"weekend_not_blocked_when_disabled": {
"input": {
"deployment": {
"environment": "development",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-12T10:00:00Z"
},
"approvals": []
},
"data": {
"config": {
"development": {"block_weekends": false}
}
},
"expect": {
"allow": true,
"violations": []
}
},
"weekend_ignored_when_config_missing": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-12T10:00:00Z"
},
"approvals": []
},
"data": {
"config": {}
},
"expect": {
"allow": true,
"violations": []
}
}
}
}
@@ -0,0 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"deploygate": {
"type": "object",
"additionalProperties": true,
"properties": {
"config": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": true,
"properties": {
"block_weekends": {"type": "boolean"}
}
}
}
}
},
"rules": {
"type": "object",
"additionalProperties": true
}
}
}
@@ -0,0 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"deployment": {
"type": "object",
"additionalProperties": true,
"required": ["environment", "created_at"],
"properties": {
"environment": {"type": "string"},
"created_at": {"type": "string", "format": "date-time"}
}
}
}
}
@@ -0,0 +1,6 @@
{
"name": "freeze-window",
"version": "0.0.0",
"description": "Denies deployments that fall inside a scheduled freeze window for the environment.",
"private": true
}
+22
View File
@@ -0,0 +1,22 @@
# METADATA
# schemas:
# - input: schema["input"]
# - data: schema["data"]
package rules.freeze_window
import rego.v1
default allow := false
allow if {
count(violations) == 0
}
violations contains {"code": "deploy_in_freeze", "message": sprintf("deployment within freeze window starting %s ending %s", [window.start, window.end])} if {
some window in data.deploygate.freeze_windows
window.environment == input.deployment.environment
created := time.parse_rfc3339_ns(input.deployment.created_at)
created >= time.parse_rfc3339_ns(window.start)
created <= time.parse_rfc3339_ns(window.end)
}
@@ -0,0 +1,12 @@
package rules.freeze_window_test
import data.rules.freeze_window as rule
import rego.v1
test_scenarios[scenario_name] if {
some scenario_name, scenario in data.scenarios
actual := {code | code := rule.violations[_].code} with input as scenario.input with data.deploygate as scenario.data
expected := {code | code := scenario.expect.violations[_]}
actual == expected
rule.allow == scenario.expect.allow with input as scenario.input with data.deploygate as scenario.data
}
@@ -0,0 +1,67 @@
{
"scenarios": {
"outside_freeze": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-15T10:00:00Z"
},
"approvals": []
},
"data": {
"freeze_windows": [
{"environment": "production", "start": "2026-12-20T00:00:00Z", "end": "2026-12-31T23:59:59Z"}
]
},
"expect": {
"allow": true,
"violations": []
}
},
"inside_freeze": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-12-24T10:00:00Z"
},
"approvals": []
},
"data": {
"freeze_windows": [
{"environment": "production", "start": "2026-12-20T00:00:00Z", "end": "2026-12-31T23:59:59Z"}
]
},
"expect": {
"allow": false,
"violations": ["deploy_in_freeze"]
}
},
"window_for_other_environment": {
"input": {
"deployment": {
"environment": "development",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-12-24T10:00:00Z"
},
"approvals": []
},
"data": {
"freeze_windows": [
{"environment": "production", "start": "2026-12-20T00:00:00Z", "end": "2026-12-31T23:59:59Z"}
]
},
"expect": {
"allow": true,
"violations": []
}
}
}
}
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"deploygate": {
"type": "object",
"additionalProperties": true,
"properties": {
"freeze_windows": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true,
"required": ["environment", "start", "end"],
"properties": {
"environment": {"type": "string"},
"start": {"type": "string", "format": "date-time"},
"end": {"type": "string", "format": "date-time"}
}
}
}
}
},
"rules": {
"type": "object",
"additionalProperties": true
}
}
}
@@ -0,0 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"deployment": {
"type": "object",
"additionalProperties": true,
"required": ["environment", "created_at"],
"properties": {
"environment": {"type": "string"},
"created_at": {"type": "string", "format": "date-time"}
}
}
}
}
@@ -0,0 +1,6 @@
{
"name": "no-self-approval",
"version": "0.0.0",
"description": "Denies deployments where the requestor approved their own deployment.",
"private": true
}
@@ -0,0 +1,25 @@
# METADATA
# schemas:
# - input: schema["input"]
# - data: schema["data"]
package rules.no_self_approval
import rego.v1
default allow := false
allow if {
count(violations) == 0
}
violations contains {"code": "self_approval", "message": sprintf("approval by %q is the deployment requestor", [input.deployment.requested_by])} if {
some approval in input.approvals
approval.active
approval.by == input.deployment.requested_by
}
violations contains {"code": "missing_requestor", "message": "deployment has no requestor"} if {
input.deployment
not input.deployment.requested_by
}
@@ -0,0 +1,12 @@
package rules.no_self_approval_test
import data.rules.no_self_approval as rule
import rego.v1
test_scenarios[scenario_name] if {
some scenario_name, scenario in data.scenarios
actual := {code | code := rule.violations[_].code} with input as scenario.input with data.deploygate as scenario.data
expected := {code | code := scenario.expect.violations[_]}
actual == expected
rule.allow == scenario.expect.allow with input as scenario.input with data.deploygate as scenario.data
}
@@ -0,0 +1,76 @@
{
"scenarios": {
"others_approve": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-15T10:00:00Z"
},
"approvals": [
{"by": "bob", "at": "2026-09-15T09:00:00Z", "active": true},
{"by": "carol", "at": "2026-09-15T09:30:00Z", "active": true}
]
},
"data": {
"config": {
"production": {"min_approvals": 2}
}
},
"expect": {
"allow": true,
"violations": []
}
},
"self_approval": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-15T10:00:00Z"
},
"approvals": [
{"by": "alice", "at": "2026-09-15T09:00:00Z", "active": true},
{"by": "bob", "at": "2026-09-15T09:30:00Z", "active": true}
]
},
"data": {
"config": {
"production": {"min_approvals": 2}
}
},
"expect": {
"allow": false,
"violations": ["self_approval"]
}
},
"revoked_self_approval_ignored": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-15T10:00:00Z"
},
"approvals": [
{"by": "alice", "at": "2026-09-15T09:00:00Z", "active": false},
{"by": "bob", "at": "2026-09-15T09:30:00Z", "active": true}
]
},
"data": {
"config": {
"production": {"min_approvals": 2}
}
},
"expect": {
"allow": true,
"violations": []
}
}
}
}
@@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"deploygate": {
"type": "object",
"additionalProperties": true
},
"rules": {
"type": "object",
"additionalProperties": true
}
}
}
@@ -0,0 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"deployment": {
"type": "object",
"additionalProperties": true,
"required": ["requested_by"],
"properties": {
"requested_by": {"type": "string"}
}
},
"approvals": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true,
"required": ["active", "by"],
"properties": {
"active": {"type": "boolean"},
"by": {"type": "string"}
}
}
}
}
}
@@ -0,0 +1,6 @@
{
"name": "require-approvals",
"version": "0.0.0",
"description": "Denies deployments that do not have a sufficient number of active approvals.",
"private": true
}
@@ -0,0 +1,34 @@
# METADATA
# schemas:
# - input: schema["input"]
# - data: schema["data"]
package rules.require_approvals
import rego.v1
default allow := false
allow if {
count(violations) == 0
}
violations contains {"code": "insufficient_approvals", "message": sprintf("need at least %d approval(s), got %d", [min_approvals, count(active_approvals)])} if {
cfg := data.deploygate.config[input.deployment.environment]
cfg.min_approvals > 0
count(active_approvals) < cfg.min_approvals
}
min_approvals := data.deploygate.config[input.deployment.environment].min_approvals if {
input.deployment
}
active_approvals contains approval if {
some approval in input.approvals
approval.active
}
violations contains {"code": "not_configured", "message": sprintf("no min_approvals configured for environment %q", [input.deployment.environment])} if {
input.deployment
not data.deploygate.config[input.deployment.environment].min_approvals
}
@@ -0,0 +1,12 @@
package rules.require_approvals_test
import data.rules.require_approvals as rule
import rego.v1
test_scenarios[scenario_name] if {
some scenario_name, scenario in data.scenarios
actual := {code | code := rule.violations[_].code} with input as scenario.input with data.deploygate as scenario.data
expected := {code | code := scenario.expect.violations[_]}
actual == expected
rule.allow == scenario.expect.allow with input as scenario.input with data.deploygate as scenario.data
}
@@ -0,0 +1,96 @@
{
"scenarios": {
"enough_approvals": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-15T10:00:00Z"
},
"approvals": [
{"by": "bob", "at": "2026-09-15T09:00:00Z", "active": true},
{"by": "carol", "at": "2026-09-15T09:30:00Z", "active": true}
]
},
"data": {
"config": {
"production": {"min_approvals": 2}
}
},
"expect": {
"allow": true,
"violations": []
}
},
"insufficient_approvals": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-15T10:00:00Z"
},
"approvals": [
{"by": "bob", "at": "2026-09-15T09:00:00Z", "active": true}
]
},
"data": {
"config": {
"production": {"min_approvals": 2}
}
},
"expect": {
"allow": false,
"violations": ["insufficient_approvals"]
}
},
"revoked_approval_not_counted": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-15T10:00:00Z"
},
"approvals": [
{"by": "bob", "at": "2026-09-15T09:00:00Z", "active": true},
{"by": "carol", "at": "2026-09-15T09:30:00Z", "active": false}
]
},
"data": {
"config": {
"production": {"min_approvals": 2}
}
},
"expect": {
"allow": false,
"violations": ["insufficient_approvals"]
}
},
"no_approval_required": {
"input": {
"deployment": {
"environment": "development",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-15T10:00:00Z"
},
"approvals": []
},
"data": {
"config": {
"development": {"min_approvals": 0}
}
},
"expect": {
"allow": true,
"violations": []
}
}
}
}
@@ -0,0 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"deploygate": {
"type": "object",
"additionalProperties": true,
"properties": {
"config": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": true,
"properties": {
"min_approvals": {"type": "integer", "minimum": 0}
}
}
}
}
},
"rules": {
"type": "object",
"additionalProperties": true
}
}
}
@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"deployment": {
"type": "object",
"additionalProperties": true,
"required": ["environment"],
"properties": {
"environment": {"type": "string"}
}
},
"approvals": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true,
"required": ["active"],
"properties": {
"active": {"type": "boolean"}
}
}
}
}
}
@@ -0,0 +1,6 @@
{
"name": "valid-environment",
"version": "0.0.0",
"description": "Denies deployments to environments that are not configured in data.deploygate.config.",
"private": true
}
@@ -0,0 +1,19 @@
# METADATA
# schemas:
# - input: schema["input"]
# - data: schema["data"]
package rules.valid_environment
import rego.v1
default allow := false
allow if {
count(violations) == 0
}
violations contains {"code": "unknown_environment", "message": sprintf("environment %q is not configured", [input.deployment.environment])} if {
input.deployment
not data.deploygate.config[input.deployment.environment]
}
@@ -0,0 +1,12 @@
package rules.valid_environment_test
import data.rules.valid_environment as rule
import rego.v1
test_scenarios[scenario_name] if {
some scenario_name, scenario in data.scenarios
actual := {code | code := rule.violations[_].code} with input as scenario.input with data.deploygate as scenario.data
expected := {code | code := scenario.expect.violations[_]}
actual == expected
rule.allow == scenario.expect.allow with input as scenario.input with data.deploygate as scenario.data
}
@@ -0,0 +1,50 @@
{
"scenarios": {
"valid_production": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-15T10:00:00Z"
},
"approvals": [
{"by": "bob", "at": "2026-09-15T09:00:00Z", "active": true}
]
},
"data": {
"config": {
"production": {"min_approvals": 2, "block_weekends": true}
}
},
"expect": {
"allow": true,
"violations": []
}
},
"unknown_environment": {
"input": {
"deployment": {
"environment": "production",
"service": "payments-api",
"version": "1.2.3",
"requested_by": "alice",
"created_at": "2026-09-15T10:00:00Z"
},
"approvals": [
{"by": "bob", "at": "2026-09-15T09:00:00Z", "active": true}
]
},
"data": {
"config": {
"staging": {"min_approvals": 1}
}
},
"expect": {
"allow": false,
"violations": ["unknown_environment"]
}
}
}
}
@@ -0,0 +1,24 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"deploygate": {
"type": "object",
"additionalProperties": true,
"required": ["config"],
"properties": {
"config": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
},
"rules": {
"type": "object",
"additionalProperties": true
}
}
}
@@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"deployment": {
"type": "object",
"additionalProperties": true,
"required": ["environment"],
"properties": {
"environment": {"type": "string"}
}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Builds deployable OPA bundles for every rule and gate.
#
# Composition model: SEPARATE EVALUATION.
# - Each rule is built as its own bundle where the module lives at data.rules.<name>.
# - Each gate bundles the policy.rego of all its dependency rules; consumers evaluate
# data.rules.<name> per rule and combine results at runtime.
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh"
rm -rf "$ARTIFACTS_DIR"
STAGE_DIR="$(mktemp -d)"
trap 'rm -rf "$STAGE_DIR"' EXIT
MANIFEST='{"revision":"","roots":["rules"],"rego_version":1}'
write_manifest() {
printf '%s' "$MANIFEST" > "$1/.manifest"
}
# --- rules ---
for d in "$POLICIES_DIR"/rules/*/; do
name="$(repo_name "$d")"
version="$(repo_version "$d")"
out="$ARTIFACTS_DIR/rules/$name/$version"
mkdir -p "$out"
echo "==> build rule $name@$version"
mkdir -p "$STAGE_DIR/rule/$name"
cp "$d/policy.rego" "$STAGE_DIR/rule/$name/policy.rego"
write_manifest "$STAGE_DIR/rule/$name"
(cd "$STAGE_DIR/rule/$name" && "$OPA" build -b . -o "$out/bundle.tar.gz")
done
# --- gates ---
for g in "$POLICIES_DIR"/gates/*/; do
name="$(repo_name "$g")"
version="$(repo_version "$g")"
out="$ARTIFACTS_DIR/gates/$name/$version"
mkdir -p "$out"
echo "==> build gate $name@$version"
stage="$STAGE_DIR/gate/$name"
for dep in $(node -p "Object.keys(require('$g/package.json').dependencies ?? {}).join(' ')"); do
for r in "$POLICIES_DIR"/rules/*/; do
rname="$(repo_name "$r")"
if [ "$rname" = "$dep" ]; then
mkdir -p "$stage/$rname"
cp "$r/policy.rego" "$stage/$rname/policy.rego"
break
fi
done
done
[ -d "$stage" ] || { echo "ERROR: gate $name has no buildable dependencies" >&2; exit 1; }
write_manifest "$stage"
(cd "$stage" && "$OPA" build -b . -o "$out/bundle.tar.gz")
done
echo "Artifacts written under $ARTIFACTS_DIR"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Lints, schema-checks and tests every rule and gate.
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh"
fail() {
echo "FAIL: $1" >&2
exit 1
}
# Rego formatting.
echo "==> opa fmt --fail"
"$OPA" fmt --fail --diff "$POLICIES_DIR" || fail "rego files are not formatted (run 'make fmt' or 'opa fmt -w')"
# Lint with regal if available.
if command -v regal >/dev/null 2>&1; then
echo "==> regal lint"
regal lint "$POLICIES_DIR" --format github || fail "regal lint found issues"
else
echo "==> regal not found, skipping"
fi
RULES_ARG=""
rule_dirs=( "$POLICIES_DIR"/rules/*/ )
[ "${#rule_dirs[@]}" -gt 0 ] && [ -d "${rule_dirs[0]}" ] || fail "no rules found"
# Schema-check + unit tests per rule (runs are isolated so each rule's scenarios.json loads at data.scenarios).
for d in "${rule_dirs[@]}"; do
name="$(basename "$d")"
echo "==> check ${name}"
"$OPA" check --strict --schema "$d/schema" "$d/policy.rego" "$d/policy_test.rego" || fail "check ${name}"
echo "==> test ${name}"
"$OPA" test "$d/policy.rego" "$d/policy_test.rego" "$d/scenarios.json" || fail "test ${name}"
RULES_ARG="$RULES_ARG $d/policy.rego"
done
# Gates: check + tests with all rule policy.rego files loaded so data.rules.* resolves.
gate_dirs=( "$POLICIES_DIR"/gates/*/ )
for g in "${gate_dirs[@]}"; do
[ -d "$g" ] || continue
name="$(basename "$g")"
echo "==> check ${name}"
"$OPA" check --strict --schema "$g/schema" "$g/gate_test.rego" || fail "check ${name}"
echo "==> test ${name}"
# shellcheck disable=SC2086
"$OPA" test "$g/gate_test.rego" "$g/scenarios.json" $RULES_ARG || fail "test ${name}"
done
echo "All checks passed."
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Shared helpers for policy management scripts.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
POLICIES_DIR="$ROOT_DIR/policies"
TOOLS_DIR="$ROOT_DIR/tools"
OPA="$TOOLS_DIR/opa"
ARTIFACTS_DIR="$ROOT_DIR/dist"
repo_name() {
node -p "require('$1/package.json').name"
}
repo_version() {
node -p "require('$1/package.json').version"
}
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Installs a pinned OPA release binary into tools/opa.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TOOLS_DIR="$ROOT_DIR/tools"
OPA_VERSION="${OPA_VERSION:-1.20.2}"
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ARCH="amd64" ;;
arm64) ARCH="arm64" ;;
esac
URL="https://openpolicyagent.org/downloads/v${OPA_VERSION}/opa_${OS}_${ARCH}_static"
mkdir -p "$TOOLS_DIR"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$URL" -o "$TOOLS_DIR/opa"
else
wget -qO "$TOOLS_DIR/opa" "$URL"
fi
chmod +x "$TOOLS_DIR/opa"
echo "Installed OPA v${OPA_VERSION} (${OS}/${ARCH}) to $TOOLS_DIR/opa"
"$TOOLS_DIR/opa" version | head -1
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Publishes every built rule/gate bundle to the Gitea generic package registry.
#
# Requires:
# - GITEA_TOKEN API token with write:package scope
# - GITEA_URL e.g. https://gitea.devegamoreno.com (default: env or https://gitea.devegamoreno.com)
# - GITEA_OWNER repo/package owner (default: git remote owner or jdevega)
#
# Registry path: /api/packages/{owner}/generic/{package}/{version}/{file}
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh"
GITEA_URL="${GITEA_URL:-https://gitea.devegamoreno.com}"
GITEA_OWNER="${GITEA_OWNER:-jdevega}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
[ -n "$GITEA_TOKEN" ] || { echo "ERROR: GITEA_TOKEN is not set" >&2; exit 1; }
publish_file() {
local pkg="$1" version="$2" file="$3"
local url="$GITEA_URL/api/packages/$GITEA_OWNER/generic/$pkg/$version/$(basename "$file")"
echo "==> publish $pkg@$version $(basename "$file")"
curl -fsSL \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--upload-file "$file" \
"$url"
echo
}
if [ ! -d "$ARTIFACTS_DIR" ]; then
echo "No artifacts found. Run 'make build' first." >&2
exit 1
fi
for bundle in "$ARTIFACTS_DIR"/rules/*/*/bundle.tar.gz; do
path="${bundle#"$ARTIFACTS_DIR/rules/"}"
name="${path%%/*}"
version="$(basename "$(dirname "$bundle")")"
publish_file "rule-$name" "$version" "$bundle"
done
for bundle in "$ARTIFACTS_DIR"/gates/*/*/bundle.tar.gz; do
path="${bundle#"$ARTIFACTS_DIR/gates/"}"
name="${path%%/*}"
version="$(basename "$(dirname "$bundle")")"
publish_file "gate-$name" "$version" "$bundle"
done
echo "Publish complete."