From aca9a5a7dc08e6562e52b126db6db7e929ca8c63 Mon Sep 17 00:00:00 2001 From: Jose Luis de Vega Andres Date: Wed, 16 Sep 2026 11:09:56 +0200 Subject: [PATCH] docs: add copilot recreation spec --- COPILOT-RECREATION.md | 2152 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2152 insertions(+) create mode 100644 COPILOT-RECREATION.md diff --git a/COPILOT-RECREATION.md b/COPILOT-RECREATION.md new file mode 100644 index 0000000..8038090 --- /dev/null +++ b/COPILOT-RECREATION.md @@ -0,0 +1,2152 @@ +# 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.` 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.. +# - Each gate bundles the policy.rego of all its dependency rules; consumers evaluate +# data.rules. 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-//bundle.tar.gz` +- `/api/packages/{owner}/generic/gate-//bundle.tar.gz` + +--- + +## 6. Rego Conventions (Uniform Contract for Every Rule) + +- **Package:** `rules.` (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..*` 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._test + +import data.rules. 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": { + "": { + "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 `. + +--- + +## 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/ + / # one deployable policy = one bundle + policy.rego # module in package rules.; 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/ + / # 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.` — 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..min_approvals`, `config..block_weekends`, `freeze_windows[]`). + +Combining policies is done **at runtime** by the consumer: load one or more bundles, evaluate +each rule under `data.rules.` 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..min_approvals` | int | require-approvals | +| `data.deploygate.config..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: { : { 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///bundle.tar.gz and dist/gates///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-//bundle.tar.gz +/api/packages/{owner}/generic/gate-//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//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= 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/...//...`), the registry upload, and (via changesets) the gate that depends on it. \ No newline at end of file