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

62 KiB

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

{
  "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

{
  "$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

# 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:

make version

This applies all changesets, rewrites the version field of affected package.jsons 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

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

#!/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

#!/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

#!/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

#!/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

#!/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.
  • allowtrue 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:

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

{
  "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:

{
  "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:

# 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:

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:

{
  "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:

{
  "$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:

{
  "$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:

{
  "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:

# 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:

{
  "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:

{
  "$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:

{
  "$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:

{
  "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:

# 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:

{
  "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:

{
  "$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:

{
  "$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:

{
  "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:

# 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:

{
  "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:

{
  "$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:

{
  "$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:

{
  "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:

# 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:

{
  "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:

{
  "$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:

{
  "$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):

{
  "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:

# 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:

{
  "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:

{
  "$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:

{
  "$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

# 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.<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

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.

CI

.gitea/workflows/ci.yml:

  1. verifynpm 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

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:

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):

{
  "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):

{
  "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.

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:

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):

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.