diff --git a/.github/workflows/git-ape-ci.yml b/.github/workflows/git-ape-ci.yml new file mode 100644 index 0000000..24deac8 --- /dev/null +++ b/.github/workflows/git-ape-ci.yml @@ -0,0 +1,224 @@ +name: "Git-Ape: Static Validation" + +# Runs every PR with parallel static-validation jobs. Complements (does not +# duplicate) the existing git-ape-actionlint, git-ape-docs-check, and +# git-ape-plugin-version-check workflows. +# +# Tools are pinned to specific versions to keep CI reproducible. + +on: + pull_request: + paths: + - '**/*.sh' + - '**/*.bash' + - '**/*.bats' + - '**/*.yml' + - '**/*.yaml' + - '**/*.md' + - '**/*.json' + - 'schemas/**' + - 'scripts/**' + - 'tests/**' + - '.github/workflows/git-ape-ci.yml' + push: + branches: + - main + paths: + - '**/*.sh' + - '**/*.bash' + - '**/*.bats' + - 'schemas/**' + - 'scripts/**' + - 'tests/**' + +permissions: + contents: read + +concurrency: + group: git-ape-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + # Pinned tool versions (bump intentionally; see schemas/README.md). + CHECK_JSONSCHEMA_VERSION: "0.37.2" + YAMLLINT_VERSION: "1.38.0" + MARKDOWNLINT_CLI_VERSION: "0.45.0" + BATS_VERSION: "1.11.1" + SHELLCHECK_VERSION: "0.10.0" + +jobs: + # --------------------------------------------------------------------------- + # Bash linting + # --------------------------------------------------------------------------- + lint-shell: + name: ShellCheck + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install shellcheck (pinned) + run: | + set -euo pipefail + curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" \ + -o shellcheck.tar.xz + tar -xJf shellcheck.tar.xz + sudo mv "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck + rm -rf "shellcheck-v${SHELLCHECK_VERSION}" shellcheck.tar.xz + shellcheck --version + + - name: Strict shellcheck on new scripts (scripts/, tests/bash/) + run: | + set -euo pipefail + mapfile -t files < <(find scripts tests/bash -type f \ + \( -name '*.sh' -o -name '*.bash' -o -name '*.bats' \) 2>/dev/null || true) + if [ "${#files[@]}" -eq 0 ]; then + echo "no new scripts to lint" + exit 0 + fi + # bats files are bash; shellcheck reads them with -s bash. + shellcheck --severity=style --shell=bash "${files[@]}" + + - name: Error-only shellcheck on existing scripts (.github/) + run: | + set -euo pipefail + # Existing scripts under .github/ predate this CI; we enforce only + # `error` severity here so genuine bugs fail CI without blocking on + # legacy SC2034/SC2045 warnings. A follow-up issue tracks cleanup. + mapfile -t files < <(find .github/scripts .github/skills -type f -name '*.sh' 2>/dev/null || true) + if [ "${#files[@]}" -eq 0 ]; then + echo "no existing scripts found" + exit 0 + fi + shellcheck --severity=error "${files[@]}" + + # --------------------------------------------------------------------------- + # YAML linting + # --------------------------------------------------------------------------- + lint-yaml: + name: yamllint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install yamllint (pinned) + run: pip install --no-cache-dir "yamllint==${YAMLLINT_VERSION}" + + - name: Run yamllint + run: | + set -euo pipefail + # Advisory in this PR (|| true); follow-up will tighten and fail. + yamllint -c .yamllint.yml \ + .github/workflows \ + schemas \ + scripts \ + tests \ + || true + + # --------------------------------------------------------------------------- + # Markdown linting + # --------------------------------------------------------------------------- + lint-markdown: + name: markdownlint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install markdownlint-cli (pinned) + run: npm install --no-fund --no-audit --global "markdownlint-cli@${MARKDOWNLINT_CLI_VERSION}" + + - name: Run markdownlint (advisory) + run: | + set -euo pipefail + # Advisory-only in this PR. The existing repo has many docs that + # predate markdownlint; a follow-up will tighten and start failing. + markdownlint \ + --ignore website/build \ + --ignore website/node_modules \ + --ignore '**/node_modules/**' \ + --disable MD013 MD033 MD041 MD024 \ + -- \ + 'README.md' \ + 'SECURITY.md' \ + 'schemas/**/*.md' \ + 'tests/**/*.md' \ + || true + + # --------------------------------------------------------------------------- + # JSON Schema validation + # --------------------------------------------------------------------------- + validate-schemas: + name: JSON Schema validation + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install check-jsonschema (pinned) + run: pip install --no-cache-dir "check-jsonschema==${CHECK_JSONSCHEMA_VERSION}" + + - name: Meta-validate schemas + run: | + set -euo pipefail + for schema in schemas/git-ape/_defs/v1.json \ + schemas/git-ape/state/v1.json \ + schemas/git-ape/metadata/v1.json \ + schemas/git-ape/security-gate/v1.json \ + schemas/git-ape/requirements/v1.json \ + schemas/git-ape/cost-estimate/v1.json \ + schemas/git-ape/policy-recommendations/v1.json \ + schemas/git-ape/plugin/v1.json; do + check-jsonschema --check-metaschema "$schema" + done + + - name: Bulk validate fixtures + plugin.json + run: bash scripts/validate-schemas.sh + + # --------------------------------------------------------------------------- + # bats-core test suite + # --------------------------------------------------------------------------- + bats-tests: + name: bats + runs-on: ubuntu-latest + needs: validate-schemas + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install check-jsonschema (pinned) + run: pip install --no-cache-dir "check-jsonschema==${CHECK_JSONSCHEMA_VERSION}" + + - name: Install bats-core (pinned) + run: | + set -euo pipefail + curl -fsSL "https://github.com/bats-core/bats-core/archive/refs/tags/v${BATS_VERSION}.tar.gz" \ + -o bats.tgz + tar -xzf bats.tgz + sudo "bats-core-${BATS_VERSION}/install.sh" /usr/local + rm -rf "bats-core-${BATS_VERSION}" bats.tgz + bats --version + + - name: Run bats suite + run: bats tests/bash diff --git a/.github/workflows/git-ape-release.yml b/.github/workflows/git-ape-release.yml index 252782a..0fe69f0 100644 --- a/.github/workflows/git-ape-release.yml +++ b/.github/workflows/git-ape-release.yml @@ -115,17 +115,28 @@ jobs: TAG: ${{ steps.ver.outputs.tag }} run: | set -euo pipefail - PREV_TAG=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || echo "") + + # Use the tag as the tip of the range when it exists as a ref. On + # workflow_dispatch with versions already aligned, the prior + # "Commit version bump" step is skipped, so the tag does not yet + # exist locally — fall back to HEAD so git log still walks the + # right commits. + if git rev-parse --verify "$TAG" >/dev/null 2>&1; then + TIP="$TAG" + else + TIP="HEAD" + fi + + PREV_TAG=$(git describe --tags --abbrev=0 "${TIP}^" 2>/dev/null || echo "") # Collect commits grouped by conventional-commit type if [[ -n "$PREV_TAG" ]]; then - RANGE="$PREV_TAG..$TAG" + RANGE="${PREV_TAG}..${TIP}" else - RANGE="$TAG" + RANGE="$TIP" fi - declare -A SECTIONS - SECTIONS=( + declare -A SECTIONS=( [feat]="" [fix]="" [docs]="" @@ -137,7 +148,7 @@ jobs: [other]="" ) - SECTION_TITLES=( + declare -A SECTION_TITLES=( [feat]="Features" [fix]="Bug Fixes" [docs]="Documentation" diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..850c379 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,17 @@ +# yamllint config used by .github/workflows/git-ape-ci.yml. +# Kept intentionally permissive in this PR; future PRs may tighten rules. +extends: default + +rules: + line-length: disable + comments-indentation: disable + document-start: disable + truthy: + allowed-values: ['true', 'false', 'on'] + indentation: + spaces: 2 + indent-sequences: consistent + braces: + max-spaces-inside: 1 + brackets: + max-spaces-inside: 1 diff --git a/schemas/README.md b/schemas/README.md new file mode 100644 index 0000000..34e1268 --- /dev/null +++ b/schemas/README.md @@ -0,0 +1,106 @@ +# Git-Ape JSON Schemas + +This directory holds the authoritative JSON Schemas (draft 2020-12) for every +JSON artifact Git-Ape emits. Schemas are versioned per artifact and validated +in CI on every PR. + +## Layout + +``` +schemas/ +├── README.md ← you are here +└── git-ape/ + ├── _defs/ + │ └── v1.json ← canonical shared types + ├── state/v1.json ← state.json artifact + ├── metadata/v1.json ← metadata.json artifact + ├── requirements/v1.json ← requirements.json artifact + ├── security-gate/v1.json ← security-gate.json artifact + ├── cost-estimate/v1.json ← cost-estimate.json artifact + ├── policy-recommendations/v1.json ← policy-recommendations.json artifact + └── plugin/v1.json ← plugin.json (this repo's manifest) +``` + +## Versioning policy + +- **Per-artifact `schemaVersion`.** Every artifact carries its own + `schemaVersion` field. The shared `$defs` in `_defs/v1.json` are versioned + together as a release train: a new `_defs/v2.json` lands when at least one + artifact graduates to v2. +- **Major bumps for breaking changes.** Renaming, removing, or retyping a + documented field. Migrate the in-tree corpus and emitter scripts in the same + PR. +- **Minor bumps for additive changes.** Adding a new optional field, relaxing a + pattern, broadening an enum. Old documents stay valid against the new schema. +- **Patch bumps are not used.** JSON Schema documents do not need patch + semantics — descriptive changes go in via PR without a version bump. + +## Strictness rules + +- `additionalProperties: false` on every top-level artifact object. +- Nested objects (e.g. `managedResources[]` items) allow extras during the + transition — they will tighten in a future minor. +- `$schema` is whitelisted as a known top-level extension key on every + artifact so editors can attach a schema without failing validation. +- Required fields are explicit. Anything not in `required` is optional and + must be omitted rather than set to `null` unless the schema documents + `null` as a valid type. + +## How emitters reference schemas + +When an emitter (e.g. a future `deploy-stack.sh`) writes an artifact, it +SHOULD set a relative `$schema` field pointing at the schema for that +artifact: + +```json +{ + "$schema": "../../../schemas/git-ape/state/v1.json", + "schemaVersion": "1.0", + "deploymentId": "deploy-20260506-001" +} +``` + +The relative path is resolved against the artifact's location. Editors with +JSON Schema support will auto-validate the file with no configuration. CI +ignores this field — `scripts/validate-schemas.sh` selects the schema based +on the file name. + +## How CI uses these schemas + +`.github/workflows/git-ape-ci.yml` invokes `scripts/validate-schemas.sh` on +every PR. The script: + +1. Walks `.azure/deployments/**/*.json` and `tests/fixtures/**/*.json`. +2. Selects the schema for each file by its base name (`state.json`, + `metadata.json`, …). +3. Runs `check-jsonschema --schemafile `. +4. Fails the job if any file violates its schema. + +Negative-test fixtures live under `tests/fixtures/_invalid/` and are +deliberately rejected by the corresponding bats test, not the bulk +validator. + +## Adding or evolving a schema + +1. Decide major vs minor (see "Versioning policy"). +2. Author or copy the schema file. +3. Update `tests/fixtures//` with a valid sample and (when adding + a new constraint) a negative sample under `tests/fixtures/_invalid/`. +4. Run `scripts/validate-schemas.sh` locally. +5. Update `tests/bash/schema-validation.bats` if you added a new artifact. +6. If you are introducing a breaking change, also add a migration step in a + follow-up PR and a one-line note in the artifact's `description`. + +## Known follow-ups + +- **Deduplicate `$defs` via cross-file `$ref`.** Today every per-artifact + schema embeds its own copy of the shared types from `_defs/v1.json`. This + keeps `check-jsonschema` invocations trivially portable. A follow-up will + switch to bundled `$ref`s using a single registry document. Tracked + alongside the doc-generation work. +- **`requirements.json` `resources[].configuration` discrimination.** The + v1.0 schema accepts any object; a v1.1 will use `oneOf` keyed on `type` so + per-resource shapes are validated. +- **SchemaStore.org submission.** Once the registry stabilises, submit a PR + to `SchemaStore/schemastore` so editors discover Git-Ape artifacts by file + pattern with no workspace setup. diff --git a/schemas/git-ape/_defs/v1.json b/schemas/git-ape/_defs/v1.json new file mode 100644 index 0000000..a65e677 --- /dev/null +++ b/schemas/git-ape/_defs/v1.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Azure/git-ape/schemas/git-ape/_defs/v1.json", + "title": "Git-Ape Shared Definitions (v1)", + "description": "Canonical shared types referenced (or duplicated until cross-file $refs are wired) by every per-artifact schema in schemas/git-ape//v1.json.", + "$defs": { + "schemaVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+$", + "description": "Major.minor version of the artifact schema. Patch versions are not used." + }, + "deploymentId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]{0,89}$", + "minLength": 1, + "maxLength": 90, + "description": "Folder name under .azure/deployments/. Lowercase alphanumerics, hyphens, periods, underscores. 1-90 characters." + }, + "azureSubscription": { + "type": "string", + "format": "uuid", + "description": "Azure subscription ID (GUID)." + }, + "azureLocation": { + "type": "string", + "pattern": "^[a-z][a-z0-9]{2,29}$", + "description": "Azure region in lowercase short form (e.g. eastus, westus2, francecentral). No display-name form (no spaces, no capitalisation)." + }, + "armResourceId": { + "type": "string", + "pattern": "^/subscriptions/[0-9a-f-]{36}(/resourceGroups/[A-Za-z0-9._()-]+)?(/providers/[^/]+/[^/]+(/[^/]+(/[^/]+/[^/]+)*)?)?$", + "description": "Full Azure Resource Manager resource ID. Subscription-scope resources omit /resourceGroups/." + }, + "armResourceType": { + "type": "string", + "pattern": "^Microsoft\\.[A-Za-z0-9]+/[A-Za-z0-9]+(/[A-Za-z0-9]+)*$", + "description": "ARM resource type, e.g. Microsoft.KeyVault/vaults." + }, + "iso8601DateTime": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 date-time. Date-only forms (YYYY-MM-DD) are not accepted; emitters MUST normalise to date-time during write." + }, + "cafAbbreviation": { + "type": "string", + "pattern": "^[a-z]{1,8}$", + "description": "Cloud Adoption Framework resource abbreviation (e.g. rg, func, st, asp, app, sql, sqldb, cosmos, appi, kv, log, cae, ca). Pattern is permissive to accommodate new abbreviations from the official CAF list." + }, + "environmentTier": { + "type": "string", + "enum": ["dev", "staging", "prod"], + "description": "Deployment environment tier." + }, + "deploymentStatus": { + "type": "string", + "enum": [ + "initialized", + "gathering-requirements", + "generating-template", + "awaiting-confirmation", + "deploying", + "testing", + "succeeded", + "failed", + "rolled-back", + "destroy-requested", + "destroyed", + "already-destroyed", + "partially-destroyed", + "retained-soft-deleted" + ], + "description": "Deployment lifecycle status. Mirrors the state machine in website/docs/deployment/state.md." + }, + "softDeletableType": { + "type": "string", + "enum": [ + "Microsoft.KeyVault/vaults", + "Microsoft.CognitiveServices/accounts", + "Microsoft.AppConfiguration/configurationStores", + "Microsoft.ApiManagement/service", + "Microsoft.MachineLearningServices/workspaces", + "Microsoft.RecoveryServices/vaults" + ], + "description": "Azure resource types that support soft-delete and require explicit purge handling on destroy." + }, + "deployMethod": { + "type": "string", + "enum": ["stack", "subscription"], + "description": "Deployment method used. 'stack' = Azure Deployment Stacks (preferred). 'subscription' = legacy az deployment sub create." + }, + "currencyCode": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code (e.g. USD, EUR)." + }, + "severityLevel": { + "type": "string", + "enum": ["Critical", "High", "Medium", "Low", "Informational"], + "description": "Severity rating for security and policy findings." + }, + "policyEffect": { + "type": "string", + "enum": ["Audit", "AuditIfNotExists", "Deny", "DeployIfNotExists", "Modify", "Disabled", "DoNotEnforce"], + "description": "Azure Policy effect. Mirrors the values supported by Azure Policy assignments." + } + } +} diff --git a/schemas/git-ape/cost-estimate/v1.json b/schemas/git-ape/cost-estimate/v1.json new file mode 100644 index 0000000..651977c --- /dev/null +++ b/schemas/git-ape/cost-estimate/v1.json @@ -0,0 +1,102 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Azure/git-ape/schemas/git-ape/cost-estimate/v1.json", + "title": "Git-Ape cost-estimate.json (v1)", + "description": "Per-resource monthly cost estimate produced by the azure-cost-estimator skill from the Azure Retail Prices API.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "deploymentId", + "generatedAt", + "currency", + "monthlyTotal", + "resources" + ], + "properties": { + "$schema": { "type": "string" }, + "schemaVersion": { "const": "1.0" }, + "deploymentId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]{0,89}$" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code. Defaults to USD." + }, + "region": { + "type": "string", + "pattern": "^[a-z][a-z0-9]{2,29}$" + }, + "monthlyTotal": { + "type": "number", + "minimum": 0, + "description": "Sum of resources[].monthlyCost. Reported separately to avoid divergence." + }, + "monthlyTotalRange": { + "type": "object", + "properties": { + "low": { "type": "number", "minimum": 0 }, + "high": { "type": "number", "minimum": 0 } + }, + "required": ["low", "high"], + "description": "Optional confidence interval when usage assumptions vary." + }, + "assumptions": { + "type": "array", + "items": { "type": "string" }, + "description": "Documented assumptions baked into the estimate (e.g. 'compute: 730 hours/month always-on')." + }, + "resources": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["type", "name", "monthlyCost"], + "properties": { + "type": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9]+\\.[A-Za-z0-9]+/[A-Za-z0-9]+(/[A-Za-z0-9]+)*$" + }, + "name": { "type": "string", "minLength": 1 }, + "sku": { "type": ["string", "object"] }, + "region": { + "type": "string", + "pattern": "^[a-z][a-z0-9]{2,29}$" + }, + "monthlyCost": { + "type": "number", + "minimum": 0 + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "meterId": { + "type": "string", + "description": "Azure Retail Prices API meter ID (when known)." + }, + "unitPrice": { + "type": "number", + "minimum": 0 + }, + "unitOfMeasure": { + "type": "string" + }, + "estimatedQuantity": { + "type": "number", + "minimum": 0 + }, + "notes": { + "type": "string" + } + } + } + } + } +} diff --git a/schemas/git-ape/metadata/v1.json b/schemas/git-ape/metadata/v1.json new file mode 100644 index 0000000..534babe --- /dev/null +++ b/schemas/git-ape/metadata/v1.json @@ -0,0 +1,144 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Azure/git-ape/schemas/git-ape/metadata/v1.json", + "title": "Git-Ape metadata.json (v1)", + "description": "Deployment metadata written by the agent during planning, updated as the deployment progresses through the lifecycle. Lives at .azure/deployments//metadata.json.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "deploymentId", + "timestamp", + "status", + "scope" + ], + "properties": { + "$schema": { "type": "string" }, + "schemaVersion": { + "const": "1.0" + }, + "deploymentId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]{0,89}$", + "minLength": 1, + "maxLength": 90 + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "user": { + "type": "string", + "description": "Email or principal name of the deployment author." + }, + "status": { + "type": "string", + "enum": [ + "initialized", + "gathering-requirements", + "generating-template", + "awaiting-confirmation", + "deploying", + "testing", + "succeeded", + "failed", + "rolled-back", + "destroy-requested", + "destroyed", + "already-destroyed", + "partially-destroyed", + "retained-soft-deleted" + ] + }, + "statusReason": { + "type": ["string", "null"], + "description": "Human-readable reason for the current status; populated for failed/destroyed states." + }, + "type": { + "type": "string", + "enum": ["new", "import", "update", "destroy"] + }, + "mode": { + "type": "string", + "enum": ["interactive", "headless"] + }, + "scope": { + "type": "string", + "enum": ["subscription", "resourceGroup", "managementGroup", "tenant"] + }, + "region": { + "type": "string", + "pattern": "^[a-z][a-z0-9]{2,29}$" + }, + "project": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "environment": { + "type": "string", + "enum": ["dev", "staging", "prod"] + }, + "deployMethod": { + "type": "string", + "enum": ["stack", "subscription"] + }, + "resourceGroup": { + "type": "string", + "maxLength": 90, + "description": "Primary resource group name. Empty string when not yet known." + }, + "resourceGroups": { + "type": "array", + "items": { "type": "string", "maxLength": 90 }, + "uniqueItems": true + }, + "resources": { + "type": "array", + "description": "Top-level resources known to the deployment. Mirrors the resources[] array in the ARM template at planning time.", + "items": { + "type": "object", + "required": ["type", "name"], + "properties": { + "type": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9]+\\.[A-Za-z0-9]+/[A-Za-z0-9]+(/[A-Za-z0-9]+)*$" + }, + "name": { "type": "string", "minLength": 1 }, + "id": { "type": "string" }, + "status": { + "type": "string", + "enum": ["pending", "provisioning", "succeeded", "failed", "deleted"] + } + } + } + }, + "resourceCount": { + "type": "integer", + "minimum": 0, + "description": "Count of top-level resources in the template." + }, + "resourceTypes": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9]+\\.[A-Za-z0-9]+/[A-Za-z0-9]+(/[A-Za-z0-9]+)*$" + }, + "uniqueItems": true + }, + "estimatedMonthlyCost": { + "oneOf": [ + { "type": "number", "minimum": 0 }, + { "type": "string", "pattern": "^\\$?[0-9]+(\\.[0-9]{1,4})?$" } + ], + "description": "Snapshot of the cost estimate at planning time. Number form preferred." + }, + "tags": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "createdBy": { + "type": "string" + } + } +} diff --git a/schemas/git-ape/plugin/v1.json b/schemas/git-ape/plugin/v1.json new file mode 100644 index 0000000..5f75e2b --- /dev/null +++ b/schemas/git-ape/plugin/v1.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Azure/git-ape/schemas/git-ape/plugin/v1.json", + "title": "Git-Ape plugin.json (v1)", + "description": "Schema for plugin.json — the chat-agents-plugin manifest at the repo root. plugin.json predates Git-Ape's per-artifact schemaVersion convention; this schema validates the manifest format consumed by the VS Code Copilot extension.", + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "description", + "version", + "author" + ], + "properties": { + "$schema": { "type": "string" }, + "name": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,63}$", + "minLength": 1, + "maxLength": 64, + "description": "Plugin identifier. Lowercase, hyphens, digits." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(-[A-Za-z0-9.-]+)?(\\+[A-Za-z0-9.-]+)?$", + "description": "Semantic version (semver 2.0.0). Pre-release and build metadata accepted." + }, + "author": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "email": { "type": "string", "format": "email" }, + "url": { "type": "string", "format": "uri" } + } + }, + "homepage": { + "type": "string", + "format": "uri" + }, + "repository": { + "oneOf": [ + { "type": "string", "format": "uri" }, + { + "type": "object", + "required": ["url"], + "properties": { + "type": { "type": "string" }, + "url": { "type": "string", "format": "uri" } + } + } + ] + }, + "bugs": { + "oneOf": [ + { "type": "string", "format": "uri" }, + { + "type": "object", + "properties": { + "url": { "type": "string", "format": "uri" }, + "email": { "type": "string", "format": "email" } + } + } + ] + }, + "license": { + "type": "string" + }, + "keywords": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true, + "maxItems": 32 + }, + "agents": { + "type": "string", + "description": "Relative path to the directory containing agent files." + }, + "skills": { + "type": "string", + "description": "Relative path to the directory containing skill files." + }, + "prompts": { + "type": "string", + "description": "Relative path to the directory containing prompt files." + }, + "instructions": { + "type": "string", + "description": "Relative path to the directory containing instruction files." + }, + "icon": { + "type": "string" + }, + "engines": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Minimum compatible runtime versions, e.g. {\"vscode\": \">=1.95.0\"}." + } + } +} diff --git a/schemas/git-ape/policy-recommendations/v1.json b/schemas/git-ape/policy-recommendations/v1.json new file mode 100644 index 0000000..f07f435 --- /dev/null +++ b/schemas/git-ape/policy-recommendations/v1.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Azure/git-ape/schemas/git-ape/policy-recommendations/v1.json", + "title": "Git-Ape policy-recommendations.json (v1)", + "description": "Output of the azure-policy-advisor skill: per-resource policy recommendations and subscription-level policy assignment proposals.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "deploymentId", + "generatedAt", + "framework", + "enforcementMode", + "recommendations" + ], + "properties": { + "$schema": { "type": "string" }, + "schemaVersion": { "const": "1.0" }, + "deploymentId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]{0,89}$" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "framework": { + "type": "string", + "description": "Compliance framework used to evaluate the template (e.g. 'azure-best-practices', 'cis-azure-v3.0', 'nist-sp-800-53-r5').", + "minLength": 1 + }, + "enforcementMode": { + "type": "string", + "enum": ["Audit", "Deny", "Disabled", "DoNotEnforce"], + "description": "Default enforcement effect proposed for new policy assignments." + }, + "subscriptionAssignments": { + "type": "array", + "description": "Currently assigned policies discovered at the target subscription scope (informational; included for context).", + "items": { + "type": "object", + "required": ["assignmentId", "policyDefinitionId"], + "properties": { + "assignmentId": { "type": "string" }, + "policyDefinitionId": { "type": "string" }, + "displayName": { "type": "string" }, + "effect": { + "type": "string", + "enum": ["Audit", "AuditIfNotExists", "Deny", "DeployIfNotExists", "Modify", "Disabled", "DoNotEnforce"] + }, + "scope": { "type": "string" } + } + } + }, + "recommendations": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "severity", "title", "policyDefinitionId", "category", "effect"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "severity": { + "type": "string", + "enum": ["Critical", "High", "Medium", "Low", "Informational"] + }, + "title": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "policyDefinitionId": { + "type": "string", + "description": "Built-in policy definition ID or relative path. Free-form to accommodate custom definitions; emitters SHOULD use the full ARM ID for built-ins." + }, + "category": { + "type": "string", + "examples": ["identity", "networking", "storage", "compute", "monitoring", "tagging"] + }, + "effect": { + "type": "string", + "enum": ["Audit", "AuditIfNotExists", "Deny", "DeployIfNotExists", "Modify", "Disabled", "DoNotEnforce"] + }, + "appliesTo": { + "type": "array", + "items": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9]+\\.[A-Za-z0-9]+/[A-Za-z0-9]+(/[A-Za-z0-9]+)*$" + }, + "resourceName": { "type": "string" } + } + } + }, + "implementation": { + "type": "object", + "properties": { + "templateChange": { "type": "string" }, + "subscriptionAssignment": { "type": "string" } + }, + "description": "Two implementation options surfaced by the advisor: in-template change vs subscription-level assignment." + }, + "evidence": { "type": "string" }, + "reference": { + "type": "string", + "format": "uri" + } + } + } + }, + "summary": { + "type": "object", + "properties": { + "total": { "type": "integer", "minimum": 0 }, + "critical": { "type": "integer", "minimum": 0 }, + "high": { "type": "integer", "minimum": 0 }, + "medium": { "type": "integer", "minimum": 0 }, + "low": { "type": "integer", "minimum": 0 } + } + } + } +} diff --git a/schemas/git-ape/requirements/v1.json b/schemas/git-ape/requirements/v1.json new file mode 100644 index 0000000..60726f8 --- /dev/null +++ b/schemas/git-ape/requirements/v1.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Azure/git-ape/schemas/git-ape/requirements/v1.json", + "title": "Git-Ape requirements.json (v1)", + "description": "User requirements collected by the Requirements Gatherer agent. v1.0 accepts any object shape inside resources[].configuration; v1.1 will discriminate per ARM resource type.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "deploymentId", + "timestamp", + "source", + "description" + ], + "properties": { + "$schema": { "type": "string" }, + "schemaVersion": { "const": "1.0" }, + "deploymentId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]{0,89}$" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "enum": ["github-issue", "interactive", "import", "api"], + "description": "How the requirements entered the system. The legacy 'issue-body' alias is migrated to 'github-issue'." + }, + "mode": { + "type": "string", + "enum": ["interactive", "headless"] + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Free-form natural-language deployment requirement." + }, + "user": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["single-resource", "multi-resource", "import"] + }, + "constraints": { + "type": "object", + "properties": { + "region": { + "type": "string", + "pattern": "^[a-z][a-z0-9]{2,29}$" + }, + "budget": { + "type": "number", + "minimum": 0 + }, + "securityLevel": { + "type": "string", + "enum": ["low", "medium", "high"] + }, + "compliance": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + } + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9]+\\.[A-Za-z0-9]+/[A-Za-z0-9]+(/[A-Za-z0-9]+)*$" + }, + "name": { "type": "string" }, + "displayName": { "type": "string" }, + "kind": { "type": "string" }, + "region": { + "type": "string", + "pattern": "^[a-z][a-z0-9]{2,29}$" + }, + "resourceGroup": { "type": "string", "maxLength": 90 }, + "cafAbbreviation": { + "type": "string", + "pattern": "^[a-z]{1,8}$" + }, + "sku": { "type": ["string", "object"] }, + "configuration": { + "type": "object", + "description": "Resource-type-specific configuration. v1.1 will discriminate this object on `type`." + } + } + } + }, + "dependencies": { + "type": "array", + "items": { + "type": "object", + "required": ["source", "target"], + "properties": { + "source": { "type": "string" }, + "target": { "type": "string" }, + "type": { "type": "string" } + } + } + }, + "validation": { + "type": "object", + "properties": { + "subscriptionAccess": { "type": "boolean" }, + "resourceGroupExists": { "type": "boolean" }, + "namesAvailable": { "type": "boolean" }, + "regionSupported": { "type": "boolean" }, + "quotaAvailable": { "type": "boolean" } + } + }, + "estimatedCost": { + "type": "number", + "minimum": 0 + }, + "requirements": { + "type": "object", + "description": "Structured requirements payload (legacy nested form). Fields here mirror top-level fields and are preserved for backward compatibility.", + "properties": { + "description": { "type": "string" }, + "constraints": { "type": "object" } + } + } + } +} diff --git a/schemas/git-ape/security-gate/v1.json b/schemas/git-ape/security-gate/v1.json new file mode 100644 index 0000000..dba16dc --- /dev/null +++ b/schemas/git-ape/security-gate/v1.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Azure/git-ape/schemas/git-ape/security-gate/v1.json", + "title": "Git-Ape security-gate.json (v1)", + "description": "Result of the security analysis gate run before deployment. v1.0 adopts the count form (criticalTotal/criticalPassed/highTotal/highPassed as integers). The legacy boolean form (criticalPassed: true) is rejected — migrate corpus before validating.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "gate", + "iterations", + "criticalTotal", + "criticalPassed", + "highTotal", + "highPassed", + "blockingFindings", + "generatedAt" + ], + "properties": { + "$schema": { "type": "string" }, + "schemaVersion": { "const": "1.0" }, + "gate": { + "type": "string", + "enum": ["PASSED", "BLOCKED", "OVERRIDDEN"] + }, + "iterations": { + "type": "integer", + "minimum": 1, + "description": "Number of times the security gate ran (re-runs after fixes increment this)." + }, + "criticalTotal": { + "type": "integer", + "minimum": 0, + "description": "Number of Critical-severity checks evaluated." + }, + "criticalPassed": { + "type": "integer", + "minimum": 0, + "description": "Number of Critical-severity checks that passed. MUST be <= criticalTotal." + }, + "highTotal": { + "type": "integer", + "minimum": 0 + }, + "highPassed": { + "type": "integer", + "minimum": 0, + "description": "MUST be <= highTotal." + }, + "mediumTotal": { + "type": "integer", + "minimum": 0 + }, + "mediumPassed": { + "type": "integer", + "minimum": 0 + }, + "lowTotal": { + "type": "integer", + "minimum": 0 + }, + "lowPassed": { + "type": "integer", + "minimum": 0 + }, + "blockingFindings": { + "type": "array", + "description": "List of finding IDs that prevented gate from passing (only relevant when gate != PASSED).", + "items": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "object", + "required": ["id", "severity"], + "properties": { + "id": { "type": "string" }, + "severity": { + "type": "string", + "enum": ["Critical", "High", "Medium", "Low", "Informational"] + }, + "title": { "type": "string" }, + "resource": { "type": "string" }, + "remediation": { "type": "string" } + } + } + ] + } + }, + "overrideReason": { + "type": ["string", "null"], + "description": "Required when gate=OVERRIDDEN. MUST be null otherwise." + }, + "overriddenBy": { + "type": ["string", "null"] + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "deploymentId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]{0,89}$" + } + }, + "allOf": [ + { + "description": "Counts must not exceed totals.", + "if": { "required": ["criticalTotal", "criticalPassed"] }, + "then": { + "properties": { + "criticalPassed": { "type": "integer" } + } + } + } + ] +} diff --git a/schemas/git-ape/state/v1.json b/schemas/git-ape/state/v1.json new file mode 100644 index 0000000..7012d5b --- /dev/null +++ b/schemas/git-ape/state/v1.json @@ -0,0 +1,169 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Azure/git-ape/schemas/git-ape/state/v1.json", + "title": "Git-Ape state.json (v1)", + "description": "Runtime deployment state captured after `az deployment sub create` or `az stack sub create` completes. Used by destroy workflows to determine teardown strategy. Documented in website/docs/deployment/state.md.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "deploymentId", + "timestamp", + "status", + "subscription", + "location", + "deployMethod", + "managedResources" + ], + "properties": { + "$schema": { + "type": "string", + "description": "Optional editor-only hint; CI ignores this and selects the schema by file name." + }, + "schemaVersion": { + "const": "1.0", + "description": "Pinned to 1.0 for this schema. Bump together with this file." + }, + "deploymentId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]{0,89}$", + "minLength": 1, + "maxLength": 90 + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "user": { + "type": "string", + "description": "Optional. Email or principal name of the human/service that triggered the deployment." + }, + "status": { + "type": "string", + "enum": [ + "initialized", + "gathering-requirements", + "generating-template", + "awaiting-confirmation", + "deploying", + "testing", + "succeeded", + "failed", + "rolled-back", + "destroy-requested", + "destroyed", + "already-destroyed", + "partially-destroyed", + "retained-soft-deleted" + ] + }, + "duration": { + "type": "string", + "pattern": "^([0-9]+s)?$", + "description": "Wall-clock duration of the deployment in `Xs` form (e.g. `210s`). Empty string when not yet known (failed before completion)." + }, + "subscription": { + "type": "string", + "format": "uuid" + }, + "location": { + "type": "string", + "pattern": "^[a-z][a-z0-9]{2,29}$" + }, + "project": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "environment": { + "type": "string", + "enum": ["dev", "staging", "prod"] + }, + "resourceGroup": { + "type": "string", + "description": "Primary resource group name. Empty string when subscription-scope deployment created no RGs.", + "maxLength": 90 + }, + "triggeredBy": { + "type": "string", + "description": "Identity that triggered the run (GitHub login, service principal name, or local user)." + }, + "triggerEvent": { + "type": "string", + "enum": ["push", "pull_request", "issue_comment", "workflow_dispatch", "schedule", "interactive"] + }, + "runId": { + "type": ["string", "null"], + "description": "GitHub Actions run ID when triggered by a workflow; null otherwise." + }, + "runUrl": { + "type": ["string", "null"], + "format": "uri", + "description": "URL to the workflow run when applicable." + }, + "stackId": { + "type": ["string", "null"], + "description": "Full ARM ID of the Azure Deployment Stack when deployMethod=stack. null for subscription-scope deployments without a stack.", + "pattern": "^(/subscriptions/[0-9a-f-]{36}/providers/Microsoft\\.Resources/deploymentStacks/[A-Za-z0-9._-]+)?$" + }, + "deployMethod": { + "type": "string", + "enum": ["stack", "subscription"] + }, + "managedResources": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "type", "scope"], + "properties": { + "id": { + "type": "string", + "pattern": "^/subscriptions/[0-9a-f-]{36}.*" + }, + "type": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9]+\\.[A-Za-z0-9]+/[A-Za-z0-9]+(/[A-Za-z0-9]+)*$" + }, + "scope": { + "type": "string", + "enum": ["resourceGroup", "subscription", "managementGroup", "tenant"] + }, + "apiVersion": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}(-preview)?$" + }, + "softDeletable": { "type": "boolean" }, + "purgeProtected": { "type": "boolean" } + } + } + }, + "resourceGroups": { + "type": "array", + "items": { "type": "string", "maxLength": 90 }, + "uniqueItems": true + }, + "subscriptions": { + "type": "array", + "items": { "type": "string", "format": "uuid" }, + "uniqueItems": true, + "minItems": 1 + }, + "externalReferences": { + "type": "array", + "items": { + "type": "object", + "required": ["kind", "targetResourceId"], + "properties": { + "kind": { + "type": "string", + "examples": ["privateEndpointConnection", "vnetPeering", "dnsZoneRecord", "kvSecretReference"] + }, + "targetResourceId": { + "type": "string", + "pattern": "^/subscriptions/[0-9a-f-]{36}.*" + } + } + } + } + } +} diff --git a/scripts/validate-schemas.sh b/scripts/validate-schemas.sh new file mode 100755 index 0000000..19a8d44 --- /dev/null +++ b/scripts/validate-schemas.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Validates every JSON artifact under .azure/deployments/ and tests/fixtures/ +# against its corresponding schema in schemas/git-ape//v1.json. +# +# The schema is selected by the artifact's base file name. Files inside any +# directory named _invalid are skipped — they are exercised by the negative +# tests in tests/bash/schema-validation.bats. +# +# Usage: scripts/validate-schemas.sh [--quiet] +# +# Exit codes: +# 0 — every scanned file validated against its schema +# 1 — at least one file failed validation +# 2 — usage / environment error +set -euo pipefail + +# Resolve repo root from this script's location (works regardless of cwd). +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +SCHEMA_ROOT="${REPO_ROOT}/schemas/git-ape" + +QUIET=0 +if [[ "${1:-}" == "--quiet" ]]; then + QUIET=1 +fi + +if ! command -v check-jsonschema >/dev/null 2>&1; then + echo "validate-schemas: check-jsonschema is required but not installed" >&2 + echo " install via: brew install check-jsonschema (macOS)" >&2 + echo " pip install check-jsonschema (Python)" >&2 + exit 2 +fi + +# Map artifact base file name → schema file. +schema_for() { + case "$1" in + state.json) echo "${SCHEMA_ROOT}/state/v1.json" ;; + metadata.json) echo "${SCHEMA_ROOT}/metadata/v1.json" ;; + security-gate.json) echo "${SCHEMA_ROOT}/security-gate/v1.json" ;; + requirements.json) echo "${SCHEMA_ROOT}/requirements/v1.json" ;; + cost-estimate.json) echo "${SCHEMA_ROOT}/cost-estimate/v1.json" ;; + policy-recommendations.json) echo "${SCHEMA_ROOT}/policy-recommendations/v1.json" ;; + plugin.json) echo "${SCHEMA_ROOT}/plugin/v1.json" ;; + *) echo "" ;; + esac +} + +failures=0 +scanned=0 +skipped=0 + +# Collect candidate files. We use NUL-separated find output to handle paths +# safely. The patterns intentionally cover only artifacts produced by the +# Git-Ape pipeline; ARM templates (template.json, parameters.json) are +# validated by Azure-side tools, not by this script. +candidates=$( + { + if [[ -d "${REPO_ROOT}/.azure/deployments" ]]; then + find "${REPO_ROOT}/.azure/deployments" -type f -name '*.json' -print0 + fi + if [[ -d "${REPO_ROOT}/tests/fixtures" ]]; then + find "${REPO_ROOT}/tests/fixtures" -type f -name '*.json' \ + -not -path '*/_invalid/*' -print0 + fi + # plugin.json at repo root. + if [[ -f "${REPO_ROOT}/plugin.json" ]]; then + printf '%s\0' "${REPO_ROOT}/plugin.json" + fi + } | tr '\0' '\n' +) + +if [[ -z "${candidates}" ]]; then + if [[ "${QUIET}" -eq 0 ]]; then + echo "validate-schemas: nothing to scan (no fixtures or deployments yet)" + fi + exit 0 +fi + +while IFS= read -r file; do + [[ -z "${file}" ]] && continue + base=$(basename "${file}") + schema=$(schema_for "${base}") + + if [[ -z "${schema}" ]]; then + if [[ "${QUIET}" -eq 0 ]]; then + echo "skip ${file#"${REPO_ROOT}"/} (no schema registered for ${base})" + fi + skipped=$((skipped + 1)) + continue + fi + + if [[ ! -f "${schema}" ]]; then + echo "fail ${file#"${REPO_ROOT}"/} (schema not found: ${schema})" >&2 + failures=$((failures + 1)) + continue + fi + + if check-jsonschema --schemafile "${schema}" "${file}" >/dev/null 2>&1; then + scanned=$((scanned + 1)) + if [[ "${QUIET}" -eq 0 ]]; then + echo "ok ${file#"${REPO_ROOT}"/} (schema: ${schema#"${REPO_ROOT}"/})" + fi + else + failures=$((failures + 1)) + echo "FAIL ${file#"${REPO_ROOT}"/}" >&2 + # Re-run to surface the actual validator output to stderr. + check-jsonschema --schemafile "${schema}" "${file}" 2>&1 | sed 's/^/ /' >&2 || true + fi +done <<< "${candidates}" + +if [[ "${QUIET}" -eq 0 ]] || [[ "${failures}" -gt 0 ]]; then + echo "---" + echo "scanned: ${scanned} skipped: ${skipped} failures: ${failures}" +fi + +if [[ "${failures}" -gt 0 ]]; then + exit 1 +fi +exit 0 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..25e2d6e --- /dev/null +++ b/tests/README.md @@ -0,0 +1,45 @@ +# tests/ + +Static-validation tests for Git-Ape. These tests do not require an Azure subscription — they exercise the JSON schemas, fixtures, and emitted artifact shapes. + +## Layout + +``` +tests/ +├── README.md ← you are here +├── fixtures/ ← canonical valid + invalid JSON samples +├── bash/ ← bats-core tests +│ └── schema-validation.bats ← positive + negative schema assertions +└── parity/ ← (placeholder) future bash↔PowerShell emitter parity tests + └── .gitkeep +``` + +## Running locally + +Prerequisites: `bash`, `bats-core`, `check-jsonschema` (and `jq` for parity tests once they land). + +On macOS: + +```bash +brew install bats-core check-jsonschema jq +``` + +On Ubuntu / GitHub Actions runners: + +```bash +sudo apt-get install -y bats jq +pipx install check-jsonschema # or: pip install --user check-jsonschema +``` + +Run the full suite from the repo root: + +```bash +scripts/validate-schemas.sh # bulk validation of every committed fixture +bats tests/bash # explicit positive + negative assertions +``` + +## What is intentionally NOT here + +- ARM-TTK / Checkov / PSRule for Azure / MSDO templateanalyzer — covered by template-side tooling, not these tests. +- Real-Azure E2E sandbox tests — gated by an `/e2e` label in a follow-up workflow. +- Agent behavioral snapshot tests — captured in a follow-up issue. diff --git a/tests/bash/schema-validation.bats b/tests/bash/schema-validation.bats new file mode 100644 index 0000000..a5de618 --- /dev/null +++ b/tests/bash/schema-validation.bats @@ -0,0 +1,126 @@ +#!/usr/bin/env bats +# +# Schema validation tests for Git-Ape JSON artifacts. +# +# These tests assert two things: +# 1. Every fixture under tests/fixtures// validates against its +# schema (matches the bulk validator behaviour). +# 2. Every fixture under tests/fixtures/_invalid/ is REJECTED by its +# schema. This is the contract for the count-form security gate +# migration and the required-field tightening on state.json. +# +# Run with: bats tests/bash + +setup() { + REPO_ROOT="$(cd "${BATS_TEST_DIRNAME}/../.." && pwd)" + SCHEMAS="${REPO_ROOT}/schemas/git-ape" + FIXTURES="${REPO_ROOT}/tests/fixtures" +} + +# ---------------------------------------------------------------------------- +# Tooling preflight +# ---------------------------------------------------------------------------- + +@test "check-jsonschema is on PATH" { + run command -v check-jsonschema + [ "$status" -eq 0 ] +} + +@test "every schema file is itself a valid JSON Schema (draft 2020-12)" { + for schema in "${SCHEMAS}"/_defs/v1.json \ + "${SCHEMAS}"/state/v1.json \ + "${SCHEMAS}"/metadata/v1.json \ + "${SCHEMAS}"/security-gate/v1.json \ + "${SCHEMAS}"/requirements/v1.json \ + "${SCHEMAS}"/cost-estimate/v1.json \ + "${SCHEMAS}"/policy-recommendations/v1.json \ + "${SCHEMAS}"/plugin/v1.json; do + run check-jsonschema --check-metaschema "${schema}" + [ "$status" -eq 0 ] + done +} + +# ---------------------------------------------------------------------------- +# plugin.json (the manifest at repo root) +# ---------------------------------------------------------------------------- + +@test "plugin.json validates against schemas/git-ape/plugin/v1.json" { + run check-jsonschema --schemafile "${SCHEMAS}/plugin/v1.json" \ + "${REPO_ROOT}/plugin.json" + [ "$status" -eq 0 ] +} + +# ---------------------------------------------------------------------------- +# Bulk validator wrapper exits clean on the committed corpus +# ---------------------------------------------------------------------------- + +@test "scripts/validate-schemas.sh passes on the committed fixtures" { + run bash "${REPO_ROOT}/scripts/validate-schemas.sh" --quiet + [ "$status" -eq 0 ] +} + +# ---------------------------------------------------------------------------- +# Positive fixtures (one @test per artifact type for clear failure messages) +# ---------------------------------------------------------------------------- + +@test "fixture: state-stack-success/state.json is valid" { + run check-jsonschema --schemafile "${SCHEMAS}/state/v1.json" \ + "${FIXTURES}/state-stack-success/state.json" + [ "$status" -eq 0 ] +} + +@test "fixture: state-stack-failed/state.json is valid" { + run check-jsonschema --schemafile "${SCHEMAS}/state/v1.json" \ + "${FIXTURES}/state-stack-failed/state.json" + [ "$status" -eq 0 ] +} + +@test "fixture: metadata-success/metadata.json is valid" { + run check-jsonschema --schemafile "${SCHEMAS}/metadata/v1.json" \ + "${FIXTURES}/metadata-success/metadata.json" + [ "$status" -eq 0 ] +} + +@test "fixture: security-gate-passed/security-gate.json (count form) is valid" { + run check-jsonschema --schemafile "${SCHEMAS}/security-gate/v1.json" \ + "${FIXTURES}/security-gate-passed/security-gate.json" + [ "$status" -eq 0 ] +} + +@test "fixture: requirements-basic/requirements.json is valid" { + run check-jsonschema --schemafile "${SCHEMAS}/requirements/v1.json" \ + "${FIXTURES}/requirements-basic/requirements.json" + [ "$status" -eq 0 ] +} + +@test "fixture: cost-estimate-basic/cost-estimate.json is valid" { + run check-jsonschema --schemafile "${SCHEMAS}/cost-estimate/v1.json" \ + "${FIXTURES}/cost-estimate-basic/cost-estimate.json" + [ "$status" -eq 0 ] +} + +@test "fixture: policy-recommendations-basic/policy-recommendations.json is valid" { + run check-jsonschema --schemafile "${SCHEMAS}/policy-recommendations/v1.json" \ + "${FIXTURES}/policy-recommendations-basic/policy-recommendations.json" + [ "$status" -eq 0 ] +} + +# ---------------------------------------------------------------------------- +# Negative fixtures (MUST be rejected — these are the migration contract) +# ---------------------------------------------------------------------------- + +@test "negative: state.json missing required fields is REJECTED" { + run check-jsonschema --schemafile "${SCHEMAS}/state/v1.json" \ + "${FIXTURES}/_invalid/state-missing-required.json" + [ "$status" -ne 0 ] + [[ "$output" == *"managedResources"* ]] || [[ "$output" == *"required"* ]] +} + +@test "negative: security-gate.json boolean form is REJECTED at v1.0" { + run check-jsonschema --schemafile "${SCHEMAS}/security-gate/v1.json" \ + "${FIXTURES}/_invalid/security-gate-boolean-form.json" + [ "$status" -ne 0 ] + # Must specifically reject because criticalPassed is True (boolean) instead + # of an integer count. + [[ "$output" == *"criticalPassed"* ]] || [[ "$output" == *"integer"* ]] +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..72aac52 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,53 @@ +# Git-Ape test fixtures + +This directory contains JSON artifact samples used by: + +- `scripts/validate-schemas.sh` — bulk validation of every committed valid sample against its schema. +- `tests/bash/schema-validation.bats` — explicit positive AND negative assertions, including for the files under `_invalid/` that the bulk validator deliberately skips. + +## Layout + +``` +tests/fixtures/ +├── README.md ← you are here +├── state-stack-success/ ← valid state.json (deployMethod=stack, status=succeeded) +│ └── state.json +├── state-stack-failed/ ← valid state.json (status=failed) +│ └── state.json +├── metadata-success/ ← valid metadata.json +│ └── metadata.json +├── security-gate-passed/ ← valid security-gate.json (count form) +│ └── security-gate.json +├── requirements-basic/ ← valid requirements.json +│ └── requirements.json +├── cost-estimate-basic/ ← valid cost-estimate.json +│ └── cost-estimate.json +├── policy-recommendations-basic/ ← valid policy-recommendations.json +│ └── policy-recommendations.json +└── _invalid/ ← deliberately broken samples (skipped by bulk validator) + ├── state-missing-required.json + └── security-gate-boolean-form.json +``` + +## Conventions + +- One artifact per folder. The folder name describes the scenario; the file name MUST be the canonical artifact name (`state.json`, `metadata.json`, …) so the bulk validator can pick the right schema. +- Folders prefixed with an underscore are excluded from the bulk validator. Use `_invalid/` for negative cases. +- Negative samples MUST also be referenced in `tests/bash/schema-validation.bats` so the failure mode is asserted, not just claimed. +- Real ARM resource IDs may be used as long as they refer to non-existent or sample subscriptions. Do **not** include real secrets, real subscription IDs from production, or anything that could be a data leak. + +## Adding a new fixture + +1. Pick a descriptive folder name: `-` (e.g. `state-multi-rg`, `policy-recommendations-empty`). +2. Drop the artifact into the folder under its canonical name. +3. Run `scripts/validate-schemas.sh` locally — it should pass. +4. If the fixture exercises a new constraint, add a matching negative sample under `_invalid/` and assert the failure in `schema-validation.bats`. +5. Mention the new fixture in your PR description. + +## Adding a new artifact type + +1. Author the schema under `schemas/git-ape//v1.json`. +2. Register the artifact's base file name in `scripts/validate-schemas.sh` (`schema_for` function). +3. Add at least one valid fixture and one negative fixture here. +4. Add explicit `@test` cases in `tests/bash/schema-validation.bats`. +5. Update `schemas/README.md`. diff --git a/tests/fixtures/_invalid/security-gate-boolean-form.json b/tests/fixtures/_invalid/security-gate-boolean-form.json new file mode 100644 index 0000000..074999a --- /dev/null +++ b/tests/fixtures/_invalid/security-gate-boolean-form.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../../schemas/git-ape/security-gate/v1.json", + "schemaVersion": "1.0", + "gate": "PASSED", + "iterations": 1, + "criticalPassed": true, + "highPassed": true, + "blockingFindings": [], + "generatedAt": "2026-05-06T08:20:00Z" +} diff --git a/tests/fixtures/_invalid/state-missing-required.json b/tests/fixtures/_invalid/state-missing-required.json new file mode 100644 index 0000000..7dabe08 --- /dev/null +++ b/tests/fixtures/_invalid/state-missing-required.json @@ -0,0 +1,11 @@ +{ + "$schema": "../../../schemas/git-ape/state/v1.json", + "schemaVersion": "1.0", + "deploymentId": "stack-broken", + "timestamp": "2026-05-06T08:30:00Z", + "user": "arnaudlh@example.com", + "status": "succeeded", + "subscription": "00000000-0000-0000-0000-000000000001", + "location": "eastus", + "deployMethod": "stack" +} diff --git a/tests/fixtures/cost-estimate-basic/cost-estimate.json b/tests/fixtures/cost-estimate-basic/cost-estimate.json new file mode 100644 index 0000000..985f054 --- /dev/null +++ b/tests/fixtures/cost-estimate-basic/cost-estimate.json @@ -0,0 +1,32 @@ +{ + "$schema": "../../../schemas/git-ape/cost-estimate/v1.json", + "schemaVersion": "1.0", + "deploymentId": "stack-storage-eastus-001", + "generatedAt": "2026-05-06T08:15:00Z", + "currency": "USD", + "region": "eastus", + "monthlyTotal": 4.32, + "monthlyTotalRange": { + "low": 3.10, + "high": 6.50 + }, + "assumptions": [ + "storage: 100 GB hot tier, LRS redundancy", + "transactions: 100k reads + 10k writes per month" + ], + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "name": "stdemodev8k3m", + "sku": "Standard_LRS", + "region": "eastus", + "monthlyCost": 4.32, + "currency": "USD", + "meterId": "00000000-0000-0000-0000-aaaaaaaaaaaa", + "unitPrice": 0.0184, + "unitOfMeasure": "1 GB/Month", + "estimatedQuantity": 100, + "notes": "Hot tier, LRS, 100 GB baseline" + } + ] +} diff --git a/tests/fixtures/metadata-success/metadata.json b/tests/fixtures/metadata-success/metadata.json new file mode 100644 index 0000000..3c02198 --- /dev/null +++ b/tests/fixtures/metadata-success/metadata.json @@ -0,0 +1,43 @@ +{ + "$schema": "../../../schemas/git-ape/metadata/v1.json", + "schemaVersion": "1.0", + "deploymentId": "stack-storage-eastus-001", + "timestamp": "2026-05-06T08:25:00Z", + "user": "arnaudlh@example.com", + "status": "succeeded", + "statusReason": null, + "type": "new", + "mode": "interactive", + "scope": "subscription", + "region": "eastus", + "project": "demo", + "environment": "dev", + "deployMethod": "stack", + "resourceGroup": "rg-demo-dev-eastus", + "resourceGroups": ["rg-demo-dev-eastus"], + "resources": [ + { + "type": "Microsoft.Resources/resourceGroups", + "name": "rg-demo-dev-eastus", + "status": "succeeded" + }, + { + "type": "Microsoft.Storage/storageAccounts", + "name": "stdemodev8k3m", + "status": "succeeded" + } + ], + "resourceCount": 2, + "resourceTypes": [ + "Microsoft.Resources/resourceGroups", + "Microsoft.Storage/storageAccounts" + ], + "estimatedMonthlyCost": 4.32, + "tags": { + "Environment": "dev", + "Project": "demo", + "ManagedBy": "git-ape-agent", + "CreatedDate": "2026-05-06" + }, + "createdBy": "arnaudlh" +} diff --git a/tests/fixtures/policy-recommendations-basic/policy-recommendations.json b/tests/fixtures/policy-recommendations-basic/policy-recommendations.json new file mode 100644 index 0000000..e20f949 --- /dev/null +++ b/tests/fixtures/policy-recommendations-basic/policy-recommendations.json @@ -0,0 +1,58 @@ +{ + "$schema": "../../../schemas/git-ape/policy-recommendations/v1.json", + "schemaVersion": "1.0", + "deploymentId": "stack-storage-eastus-001", + "generatedAt": "2026-05-06T08:18:00Z", + "framework": "azure-best-practices", + "enforcementMode": "Audit", + "subscriptionAssignments": [], + "recommendations": [ + { + "id": "rec-storage-https-only", + "severity": "High", + "title": "Storage accounts should require secure transfer", + "description": "Audit storage accounts where supportsHttpsTrafficOnly is false.", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/404c3081-a854-4457-ae30-26a93ef643f9", + "category": "storage", + "effect": "Audit", + "appliesTo": [ + { + "type": "Microsoft.Storage/storageAccounts", + "resourceName": "stdemodev8k3m" + } + ], + "implementation": { + "templateChange": "Already applied: supportsHttpsTrafficOnly=true.", + "subscriptionAssignment": "Recommended at subscription scope to enforce on future accounts." + }, + "evidence": "Template property properties.supportsHttpsTrafficOnly is set to true.", + "reference": "https://learn.microsoft.com/azure/storage/common/storage-require-secure-transfer" + }, + { + "id": "rec-storage-tls-min", + "severity": "Medium", + "title": "Storage accounts should use minimum TLS 1.2", + "description": "Audit storage accounts where minimumTlsVersion is below TLS1_2.", + "policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/fe83a0eb-a853-422d-aac2-1bffd182c5d0", + "category": "storage", + "effect": "Audit", + "appliesTo": [ + { + "type": "Microsoft.Storage/storageAccounts", + "resourceName": "stdemodev8k3m" + } + ], + "implementation": { + "templateChange": "Already applied: minimumTlsVersion=TLS1_2.", + "subscriptionAssignment": "Optional at subscription scope." + } + } + ], + "summary": { + "total": 2, + "critical": 0, + "high": 1, + "medium": 1, + "low": 0 + } +} diff --git a/tests/fixtures/requirements-basic/requirements.json b/tests/fixtures/requirements-basic/requirements.json new file mode 100644 index 0000000..43a9a17 --- /dev/null +++ b/tests/fixtures/requirements-basic/requirements.json @@ -0,0 +1,43 @@ +{ + "$schema": "../../../schemas/git-ape/requirements/v1.json", + "schemaVersion": "1.0", + "deploymentId": "stack-storage-eastus-001", + "timestamp": "2026-05-06T08:10:00Z", + "source": "github-issue", + "mode": "interactive", + "description": "Deploy a Storage Account in East US for the demo project, dev environment, with HTTPS-only and TLS 1.2 minimum.", + "user": "arnaudlh", + "type": "single-resource", + "constraints": { + "region": "eastus", + "budget": 25, + "securityLevel": "high", + "compliance": ["azure-best-practices"] + }, + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "name": "stdemodev8k3m", + "displayName": "Demo Storage", + "kind": "StorageV2", + "region": "eastus", + "resourceGroup": "rg-demo-dev-eastus", + "cafAbbreviation": "st", + "sku": "Standard_LRS", + "configuration": { + "supportsHttpsTrafficOnly": true, + "minimumTlsVersion": "TLS1_2", + "allowSharedKeyAccess": false + } + } + ], + "dependencies": [], + "validation": { + "subscriptionAccess": true, + "resourceGroupExists": false, + "namesAvailable": true, + "regionSupported": true, + "quotaAvailable": true + }, + "estimatedCost": 4.32 +} diff --git a/tests/fixtures/security-gate-passed/security-gate.json b/tests/fixtures/security-gate-passed/security-gate.json new file mode 100644 index 0000000..6f07a67 --- /dev/null +++ b/tests/fixtures/security-gate-passed/security-gate.json @@ -0,0 +1,19 @@ +{ + "$schema": "../../../schemas/git-ape/security-gate/v1.json", + "schemaVersion": "1.0", + "gate": "PASSED", + "iterations": 2, + "criticalTotal": 5, + "criticalPassed": 5, + "highTotal": 8, + "highPassed": 8, + "mediumTotal": 3, + "mediumPassed": 2, + "lowTotal": 1, + "lowPassed": 1, + "blockingFindings": [], + "overrideReason": null, + "overriddenBy": null, + "generatedAt": "2026-05-06T08:20:00Z", + "deploymentId": "stack-storage-eastus-001" +} diff --git a/tests/fixtures/state-stack-failed/state.json b/tests/fixtures/state-stack-failed/state.json new file mode 100644 index 0000000..c240050 --- /dev/null +++ b/tests/fixtures/state-stack-failed/state.json @@ -0,0 +1,24 @@ +{ + "$schema": "../../../schemas/git-ape/state/v1.json", + "schemaVersion": "1.0", + "deploymentId": "stack-kv-westus2-002", + "timestamp": "2026-05-06T09:15:00Z", + "user": "arnaudlh@example.com", + "status": "failed", + "duration": "47s", + "subscription": "00000000-0000-0000-0000-000000000002", + "location": "westus2", + "project": "kvtest", + "environment": "dev", + "resourceGroup": "rg-kvtest-dev-westus2", + "triggeredBy": "arnaudlh", + "triggerEvent": "workflow_dispatch", + "runId": null, + "runUrl": null, + "stackId": null, + "deployMethod": "subscription", + "managedResources": [], + "resourceGroups": [], + "subscriptions": ["00000000-0000-0000-0000-000000000002"], + "externalReferences": [] +} diff --git a/tests/fixtures/state-stack-success/state.json b/tests/fixtures/state-stack-success/state.json new file mode 100644 index 0000000..7e2e1a3 --- /dev/null +++ b/tests/fixtures/state-stack-success/state.json @@ -0,0 +1,41 @@ +{ + "$schema": "../../../schemas/git-ape/state/v1.json", + "schemaVersion": "1.0", + "deploymentId": "stack-storage-eastus-001", + "timestamp": "2026-05-06T08:30:00Z", + "user": "arnaudlh@example.com", + "status": "succeeded", + "duration": "182s", + "subscription": "00000000-0000-0000-0000-000000000001", + "location": "eastus", + "project": "demo", + "environment": "dev", + "resourceGroup": "rg-demo-dev-eastus", + "triggeredBy": "arnaudlh", + "triggerEvent": "pull_request", + "runId": "12345678901", + "runUrl": "https://github.com/arnaudlh/git-ape/actions/runs/12345678901", + "stackId": "/subscriptions/00000000-0000-0000-0000-000000000001/providers/Microsoft.Resources/deploymentStacks/stack-storage-eastus-001", + "deployMethod": "stack", + "managedResources": [ + { + "id": "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-demo-dev-eastus", + "type": "Microsoft.Resources/resourceGroups", + "scope": "subscription", + "apiVersion": "2024-03-01", + "softDeletable": false, + "purgeProtected": false + }, + { + "id": "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-demo-dev-eastus/providers/Microsoft.Storage/storageAccounts/stdemodev8k3m", + "type": "Microsoft.Storage/storageAccounts", + "scope": "resourceGroup", + "apiVersion": "2023-05-01", + "softDeletable": false, + "purgeProtected": false + } + ], + "resourceGroups": ["rg-demo-dev-eastus"], + "subscriptions": ["00000000-0000-0000-0000-000000000001"], + "externalReferences": [] +} diff --git a/tests/parity/.gitkeep b/tests/parity/.gitkeep new file mode 100644 index 0000000..b2d2b63 --- /dev/null +++ b/tests/parity/.gitkeep @@ -0,0 +1,3 @@ +Placeholder for bash↔PowerShell emitter parity tests. + +These tests will land in a follow-up PR after #44 (the deploy-stack / destroy-stack scripts) merges into main. The plan: invoke both bash and PowerShell emitters with identical inputs and assert their state.json output is byte-for-byte equivalent (modulo timestamp).