Files
deployment-policies/COPILOT-RECREATION.md
jdevega aca9a5a7dc
ci / verify (push) Failing after 1m40s
ci / publish (push) Skipped
docs: add copilot recreation spec
2026-09-16 11:09:56 +02:00

2152 lines
62 KiB
Markdown

# Repository Reproduction Spec: deployment-policies
Use this document to recreate the `jdevega/deployment-policies` repository from scratch with GitHub Copilot. Every file and its exact content is included below. Recreate the full directory tree, all files verbatim, then run the verification steps at the end.
---
## 1. Project Overview
A **policy-as-code monorepo** containing small, composable OPA/Rego policies that act as **deployment gateways**. Each policy rule answers one narrow question about a deployment request (Is the environment valid? Are there enough approvals? etc.), and "gates" bundle multiple rules together for convenience. Policies are versioned, tested, linted, built into OPA bundles, and published to a Gitea generic package registry.
**Core design principle — "Separate Evaluation":**
- Each rule is an **independent npm package + independent OPA bundle**.
- Rules **never import from each other** (zero cross-file imports between rules).
- Gates **compose rules at build time** by including each dependency's `policy.rego` in one bundle.
- Consumers evaluate each rule under `data.rules.<name>` separately and fuse `violations`/`allow` at runtime.
- Config (`data.deploygate.*`) is never baked into bundles; it is supplied at evaluation time so rules stay environment-agnostic.
**Repository host:** self-hosted Gitea 1.27.0 at `gitea.devegamoreno.com`, repo owner `jdevega`.
---
## 2. Tech Stack
| Layer | Technology | Version | Purpose |
|---|---|---|---|
| Policy language | Rego (`rego.v1`) | OPA 1.x | Authoring deployment-gateway rules and gates |
| Policy engine | OPA | `v1.20.2` (pinned, static binary via download, **no Docker**) | Evaluate, test, type-check, bundle |
| Policy linter | regal | `v0.42.0` | Static linting (optional; skipped if absent) |
| Schemas | JSON Schema | draft 2020-12 | Static `input`/`data` validation via `opa check --strict --schema` |
| Versioning | Changesets CLI | `@changesets/cli@^3.0.3` | Per-package version bumps across npm workspaces |
| Changelog formatting | oxfmt | `^0.68.0` | Format changelog markdown (`format: "oxfmt"`) |
| Package management | npm workspaces | npm 11 / Node 24 | Monorepo layout |
| CI/CD | Gitea Actions | Gitea 1.27.0 | verify + build + publish on `main` |
| Distribution | Gitea generic package registry | — | Stores rule/gate `.tar.gz` bundles |
| Build tooling | GNU make + bash | — | `Makefile` wraps `scripts/*.sh` |
| Runtime | Node.js | >= 22.11 | Used ONLY to `require('./package.json')` to extract name/version in bash scripts — no JS application code exists |
**Not used (by design):** Docker/containers, pnpm/yarn, prettier-plugin-rego (does not exist on npm). Rego formatting is owned by `opa fmt`.
---
## 3. Repository Layout
```
deployment-policies/
.changeset/
config.json
README.md
.gitea/
workflows/
ci.yml
.gitignore
COPILOT-RECREATION.md # this spec (not part of the other repo)
Makefile
README.md
docs/
tech-stack.md
package.json
package-lock.json
policies/
rules/
block-weekends/
package.json
policy.rego
policy_test.rego
scenarios.json
schema/
data.json
input.json
freeze-window/ # (same structure)
no-self-approval/ # (same structure)
require-approvals/ # (same structure)
valid-environment/ # (same structure)
gates/
deploy-gate/
package.json
gate_test.rego
scenarios.json
schema/
data.json
input.json
scripts/
build.sh
check.sh
common.sh
install-opa.sh
publish.sh
```
---
## 4. Root Configuration Files
### 4.1 `.gitignore`
```
node_modules/
dist/
tools/opa
*.log
.DS_Store
```
### 4.2 `package.json`
```json
{
"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"
}
}
```
Generate `package-lock.json` with `npm install` / `npm ci`.
### 4.3 `.changeset/config.json`
```json
{
"$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
}
}
```
### 4.4 `.changeset/README.md`
```markdown
# 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
`DEPLOY_TOKEN` repository secret (requires `write:package` scope). Secret names cannot start
with the reserved `GITEA_` prefix.
```
### 4.5 `.gitea/workflows/ci.yml`
```yaml
name: ci
on:
push:
branches:
- main
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
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@v7
- uses: actions/setup-node@v7
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.DEPLOY_TOKEN }}
run: ./scripts/publish.sh
```
**CI secret note:** Gitea reserves secret names starting with `GITEA_`, so the publish token is named `DEPLOY_TOKEN` (scope `write:package`) and is exported to the job as `GITEA_TOKEN`.
### 4.6 `Makefile`
```make
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 env)"
@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
```
---
## 5. Build Scripts
### 5.1 `scripts/common.sh`
```bash
#!/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"
}
```
### 5.2 `scripts/install-opa.sh`
```bash
#!/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
```
### 5.3 `scripts/check.sh`
```bash
#!/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."
```
### 5.4 `scripts/build.sh`
```bash
#!/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"
```
### 5.5 `scripts/publish.sh`
```bash
#!/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."
```
Registry artifacts land at:
- `/api/packages/{owner}/generic/rule-<rule>/<version>/bundle.tar.gz`
- `/api/packages/{owner}/generic/gate-<gate>/<version>/bundle.tar.gz`
---
## 6. Rego Conventions (Uniform Contract for Every Rule)
- **Package:** `rules.<snake_case_name>` (e.g. `rules.valid_environment`).
- **`default allow := false`** — deny by default.
- **`allow`** — `true` iff `count(violations) == 0`.
- **`violations`** — a set of `{"code": string, "message": string}` objects describing denial reasons.
- **Input** — read from `input.deployment.*` and `input.approvals[]`.
- **Config data** — read from `data.deploygate.config.<env>.*` and `data.deploygate.freeze_windows[]`.
- **METADATA annotation** at the top of every `.rego`:
```
# METADATA
# schemas:
# - input: schema["input"]
# - data: schema["data"]
```
- All files use `import rego.v1` (OPA 1.x canonical syntax).
### Testing pattern (identical for every rule)
`policy_test.rego` is a **single generative test** iterating over `data.scenarios` (loaded from the rule's `scenarios.json` because the top-level key there is `scenarios`). It asserts both the set of violation codes and the `allow` boolean:
```rego
package rules.<name>_test
import data.rules.<name> 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
}
```
### `scenarios.json` shape
```json
{
"scenarios": {
"<scenario_name>": {
"input": { ... },
"data": { "config": { ... }, "freeze_windows": [ ... ] },
"expect": { "allow": true|false, "violations": ["code1", "..."] }
}
}
}
```
### Schema files pattern
`schema/input.json` and `schema/data.json` use JSON Schema **draft 2020-12**, declare only the fields the rule needs, and use `additionalProperties: true` liberally. `schema/data.json` always includes a `deploygate` object (with the specific fields the rule reads) plus a `rules`/`gate`/`scenarios` passthrough. Verified in CI with `opa check --strict --schema <dir>`.
---
## 7. Policy Rules (5)
### 7.1 valid-environment
`policies/rules/valid-environment/package.json`:
```json
{
"name": "valid-environment",
"version": "0.0.0",
"description": "Denies deployments to environments that are not configured in data.deploygate.config.",
"private": true
}
```
`policies/rules/valid-environment/policy.rego`:
```rego
# 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]
}
```
`policies/rules/valid-environment/policy_test.rego`:
```rego
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
}
```
`policies/rules/valid-environment/scenarios.json`:
```json
{
"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"]
}
}
}
}
```
`policies/rules/valid-environment/schema/input.json`:
```json
{
"$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"}
}
}
}
}
```
`policies/rules/valid-environment/schema/data.json`:
```json
{
"$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
}
}
}
```
---
### 7.2 require-approvals
`policies/rules/require-approvals/package.json`:
```json
{
"name": "require-approvals",
"version": "0.0.0",
"description": "Denies deployments that do not have a sufficient number of active approvals.",
"private": true
}
```
`policies/rules/require-approvals/policy.rego`:
```rego
# 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
}
```
`policies/rules/require-approvals/policy_test.rego` — same generative test as above with `package rules.require_approvals_test` and `import data.rules.require_approvals as rule`.
`policies/rules/require-approvals/scenarios.json`:
```json
{
"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": []
}
}
}
}
```
`policies/rules/require-approvals/schema/input.json`:
```json
{
"$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"}
}
}
}
}
}
```
`policies/rules/require-approvals/schema/data.json`:
```json
{
"$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
}
}
}
```
---
### 7.3 no-self-approval
`policies/rules/no-self-approval/package.json`:
```json
{
"name": "no-self-approval",
"version": "0.0.0",
"description": "Denies deployments where the requestor approved their own deployment.",
"private": true
}
```
`policies/rules/no-self-approval/policy.rego`:
```rego
# 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
}
```
`policies/rules/no-self-approval/policy_test.rego` — same generative test with `package rules.no_self_approval_test` and `import data.rules.no_self_approval as rule`.
`policies/rules/no-self-approval/scenarios.json`:
```json
{
"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": []
}
}
}
}
```
`policies/rules/no-self-approval/schema/input.json`:
```json
{
"$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"}
}
}
}
}
}
```
`policies/rules/no-self-approval/schema/data.json`:
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": true,
"properties": {
"deploygate": {
"type": "object",
"additionalProperties": true
},
"rules": {
"type": "object",
"additionalProperties": true
}
}
}
```
---
### 7.4 block-weekends
`policies/rules/block-weekends/package.json`:
```json
{
"name": "block-weekends",
"version": "0.0.0",
"description": "Denies deployments that fall on a weekend when the environment has block_weekends enabled.",
"private": true
}
```
`policies/rules/block-weekends/policy.rego`:
```rego
# 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
}
```
`policies/rules/block-weekends/policy_test.rego` — same generative test with `package rules.block_weekends_test` and `import data.rules.block_weekends as rule`.
`policies/rules/block-weekends/scenarios.json`:
```json
{
"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": []
}
}
}
}
```
`policies/rules/block-weekends/schema/input.json`:
```json
{
"$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"}
}
}
}
}
```
`policies/rules/block-weekends/schema/data.json`:
```json
{
"$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
}
}
}
```
---
### 7.5 freeze-window
`policies/rules/freeze-window/package.json`:
```json
{
"name": "freeze-window",
"version": "0.0.0",
"description": "Denies deployments that fall inside a scheduled freeze window for the environment.",
"private": true
}
```
`policies/rules/freeze-window/policy.rego`:
```rego
# 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)
}
```
`policies/rules/freeze-window/policy_test.rego` — same generative test with `package rules.freeze_window_test` and `import data.rules.freeze_window as rule`.
`policies/rules/freeze-window/scenarios.json`:
```json
{
"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": []
}
}
}
}
```
`policies/rules/freeze-window/schema/input.json`:
```json
{
"$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"}
}
}
}
}
```
`policies/rules/freeze-window/schema/data.json`:
```json
{
"$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
}
}
}
```
---
## 8. Gate (1)
### 8.1 deploy-gate
`policies/gates/deploy-gate/package.json` — lists all 5 rules as npm `dependencies` (used by `build.sh` to know which `policy.rego` files to bundle):
```json
{
"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"
}
}
```
`policies/gates/deploy-gate/gate_test.rego` — integration test: imports all 5 rules, aggregates their violations per scenario, and asserts the expected code set:
```rego
# 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
}
```
`policies/gates/deploy-gate/scenarios.json`:
```json
{
"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"]
}
}
}
}
```
`policies/gates/deploy-gate/schema/input.json` — union of all rule input requirements:
```json
{
"$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"}
}
}
}
}
}
```
`policies/gates/deploy-gate/schema/data.json`:
```json
{
"$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
}
}
}
```
---
## 9. README / Documentation
### 9.1 `README.md`
```markdown
# 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`)
In CI, set a secret named `DEPLOY_TOKEN` on the repository — the secret name must not start with
the reserved `GITEA_` prefix — and `publish.sh` exposes it to the job as `GITEA_TOKEN`.
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 the `DEPLOY_TOKEN` secret.
Create the `DEPLOY_TOKEN` repository secret on Gitea with at least `write:package` scope
(secret names cannot start with `GITEA_`).
## 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.
```
### 9.2 `docs/tech-stack.md`
```markdown
# 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 the `DEPLOY_TOKEN` secret (scope `write:package`) and
`GITEA_OWNER`/`GITEA_URL`; publish job runs only on `main`. Secret names must not start with
the reserved `GITEA_` prefix.
- **`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`
```
---
## 10. Input / Data Contracts (for the consumer documentation)
**Input (a deployment request):**
```json
{
"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 (`deploygate.json`, supplied at evaluation time, never baked into bundles):**
```json
{
"deploygate": {
"config": {
"production": {"min_approvals": 2, "block_weekends": true},
"staging": {"min_approvals": 1, "block_weekends": false}
},
"freeze_windows": [
{"environment": "production", "start": "2026-12-20T00:00:00Z", "end": "2026-12-31T23:59:59Z"}
]
}
}
```
### Available violation codes
| Code | Emitted by | Meaning |
|---|---|---|
| `unknown_environment` | valid-environment | env not in `data.deploygate.config` |
| `insufficient_approvals` | require-approvals | fewer active approvals than `min_approvals` |
| `not_configured` | require-approvals | no `min_approvals` configured for the env |
| `self_approval` | no-self-approval | active approver is the requestor |
| `missing_requestor` | no-self-approval | `deployment.requested_by` absent |
| `weekend_deploy` | block-weekends | deploy on Sat/Sun with `block_weekends` enabled |
| `deploy_in_freeze` | freeze-window | deploy inside a matching freeze window |
---
## 11. Reference: `package-lock.json`
Do not hand-write it. Generate it by running `npm install` at the root after creating `package.json` (npm 11 generates a lockfile v3). Commit the generated file. `npm ci` in CI depends on it.
---
## 12. Verification Steps (run from repo root after recreating)
Prerequisites: Node.js ≥ 22.11, npm ≥ 10.9, `make`, `curl` (or `wget`). The target repo lives on a Gitea instance.
```sh
make install # downloads OPA v1.20.2 static binary to tools/opa
npm ci # installs @changesets/cli + oxfmt from lockfile
make check # opa fmt --fail + (optional) regal lint + opa check --strict --schema + opa test for all 5 rules and deploy-gate
make build # produces dist/rules/{valid-environment,require-approvals,no-self-approval,block-weekends,freeze-window}/0.0.0/bundle.tar.gz and dist/gates/deploy-gate/0.0.0/bundle.tar.gz
```
Expected results:
- `make check` prints `All checks passed.` and all per-rule/gate `check`/`test` lines succeed.
- `make build` ends with `Artifacts written under dist/` and exactly 6 `bundle.tar.gz` files exist (5 rules + 1 gate).
Optional smoke test of a built bundle:
```sh
echo '{"deploygate":{"config":{"production":{"min_approvals":2}}}}' > /tmp/dg.json
echo '{"deployment":{"environment":"production","service":"s","version":"1.0.0","requested_by":"alice","created_at":"2026-09-15T10:00:00Z"},"approvals":[{"by":"bob","active":true}]}' > /tmp/req.json
tools/opa eval --bundle dist/rules/require-approvals/0.0.0/bundle.tar.gz --data /tmp/dg.json --input /tmp/req.json 'data.rules.require_approvals'
# expect allow=false, violations contains insufficient_approvals
```
To publish to Gitea (needs Gitea instance + a `DEPLOY_TOKEN` / `GITEA_TOKEN` with `write:package` scope):
```sh
GITEA_URL=https://gitea.example.com GITEA_OWNER=owner GITEA_TOKEN=<token> make publish
```
The Gitea secret for CI must be named `DEPLOY_TOKEN` (Gitea reserves names starting with `GITEA_`) and the workflow exposes it to `publish.sh` as `GITEA_TOKEN`.
---
## 13. Behavioral Contract (sets full requirements for a faithful reimplementation)
1. Adding/editing a `.rego` file must keep valid `opa fmt` output (`make fmt` then `make check` must pass).
2. Adding/editing a rule's scenarios must not break schema `opa check --strict --schema` (rules' JSON Schemas must stay consistent with the fields the rule actually reads).
3. `scenarios.json` top-level key must be `scenarios` (OPA loads JSON data files by their top-level key, not filename).
4. No rule may import another rule's package; gates alone combine rule modules, and only via the dependency list in their `package.json`.
5. Bundles must scope roots to `rules` (manifest `{"roots":["rules"],"rego_version":1}`), letting `data.deploygate.*` be supplied at evaluation time.
6. Version numbers come from each package's `package.json`; bumping a rule's version must flow into the bundle path (`dist/.../<version>/...`), the registry upload, and (via changesets) the gate that depends on it.