diff --git a/README.md b/README.md index cbba11a..c7a66af 100644 --- a/README.md +++ b/README.md @@ -1,572 +1,7 @@ -# `` Delimit GitHub Action - -**Catch breaking API changes before merge** — semver classification, migration guides, and policy enforcement for OpenAPI specs. - -[![GitHub Marketplace](https://img.shields.io/badge/Marketplace-Delimit-blue)](https://github.com/marketplace/actions/delimit-api-governance) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -[![API Governance](https://delimit-ai.github.io/badge/pass.svg)](https://github.com/marketplace/actions/delimit-api-governance) - -Delimit runs on every pull request, compares your OpenAPI spec against the base branch, and posts a detailed comment with breaking changes, semver classification, policy violations, and migration guidance. No API keys, no external services, no config required to get started. - -## What it looks like - -

- Delimit PR comment showing breaking changes -

- ---- - -## Features - -- **Breaking change detection** — catches 27 types of changes (17 breaking, 10 non-breaking) across endpoints, parameters, response schemas, types, enums, security, and constraints -- **Semver classification** — deterministic `major` / `minor` / `patch` / `none` bump recommendation with computed next version -- **Migration guides** — auto-generated step-by-step migration instructions for every breaking change -- **PR comments** — rich Markdown summary posted directly on your pull request, updated on each push -- **Advisory and enforce modes** — start with non-blocking warnings, promote to CI-gating when ready -- **Custom policies** — define your own governance rules in `.delimit/policies.yml` with path patterns, severity levels, and custom messages -- **7 explainer templates** — developer, team lead, product, migration, changelog, PR comment, and Slack formats - ---- - -## Quick Start - -Add this file to `.github/workflows/api-check.yml`: - -```yaml -name: API Contract Check -on: pull_request - -jobs: - delimit: - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - uses: actions/checkout@v4 - - uses: delimit-ai/delimit-action@v1 - with: - spec: api/openapi.yaml -``` - -That is it. Delimit auto-fetches the base branch version of your spec and diffs it against the PR changes. Runs in **advisory mode** by default — posts a PR comment but never fails your build. - -### What the PR comment looks like - -When Delimit detects breaking changes, it posts a comment like this: - -> **Delimit API Governance** | Breaking Changes Detected -> -> | Change | Path | Severity | -> |--------|------|----------| -> | endpoint_removed | `DELETE /pets/{petId}` | error | -> | type_changed | `/pets:GET:200[].id` (string → integer) | warning | -> | enum_value_removed | `/pets:GET:200[].status` | warning | -> -> **Semver**: MAJOR (1.0.0 → 2.0.0) -> ->
Migration Guide (3 steps) -> -> **Step 1**: `DELETE /pets/{petId}` was removed. Update clients to use an alternative endpoint or remove calls to this path. -> -> **Step 2**: `id` changed from `string` to `integer`. Update serialization logic, type assertions, and database column types. -> -> **Step 3**: `status` enum value `"pending"` was removed. Update clients to stop sending this value. -> ->
- -See the [live demo](https://github.com/delimit-ai/delimit-action-demo/pull/2) — a Users API migration with 23 breaking changes detected across 27 change types, severity badges, and a migration guide. - -### Advanced: explicit base and head specs - -If you need to compare specific files (e.g., pre-checked-out base branch), use `old_spec` and `new_spec` instead: - -```yaml - - uses: delimit-ai/delimit-action@v1 - with: - old_spec: base/api/openapi.yaml - new_spec: api/openapi.yaml -``` - ---- - -## Full Usage - -```yaml -name: API Governance -on: pull_request - -jobs: - api-check: - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - uses: actions/checkout@v4 - - - name: Checkout base spec - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: base - - - uses: delimit-ai/delimit-action@v1 - id: delimit - with: - old_spec: base/api/openapi.yaml - new_spec: api/openapi.yaml - mode: enforce - policy_file: .delimit/policies.yml - github_token: ${{ secrets.GITHUB_TOKEN }} - - - name: Use outputs - if: always() - run: | - echo "Breaking changes: ${{ steps.delimit.outputs.breaking_changes_detected }}" - echo "Violations: ${{ steps.delimit.outputs.violations_count }}" - echo "Semver bump: ${{ steps.delimit.outputs.semver_bump }}" - echo "Next version: ${{ steps.delimit.outputs.next_version }}" -``` - ---- - -## Inputs - -| Input | Required | Default | Description | -|-------|----------|---------|-------------| -| `spec` | No | `''` | Path to the changed OpenAPI spec. On pull requests, Delimit auto-fetches the base branch version for comparison. | -| `old_spec` | No | `''` | Path to the old/base API specification file. | -| `new_spec` | No | `''` | Path to the new/changed API specification file. | -| `mode` | No | `advisory` | `advisory` (comments only) or `enforce` (fails CI on breaking changes). | -| `github_token` | No | `${{ github.token }}` | GitHub token used to post PR comments. | -| `policy_file` | No | `''` | Path to a custom policy file (e.g., `.delimit/policies.yml`). | - -> **Note**: Provide either `spec` for pull request workflows, or both `old_spec` and `new_spec` for explicit comparisons. If neither form is provided, the action exits with an error. - ---- - -## Outputs - -| Output | Type | Description | -|--------|------|-------------| -| `breaking_changes_detected` | `string` | `"true"` if any breaking change was found, `"false"` otherwise. | -| `violations_count` | `string` | Number of policy violations (errors + warnings). | -| `semver_bump` | `string` | Recommended version bump: `major`, `minor`, `patch`, or `none`. | -| `next_version` | `string` | Computed next version string (e.g., `2.0.0`). | -| `report` | `string` | Full JSON report of all detected changes, violations, and semver data. | - -### Using outputs in subsequent steps - -```yaml -- uses: delimit-ai/delimit-action@v1 - id: delimit - with: - old_spec: base/api/openapi.yaml - new_spec: api/openapi.yaml - -- name: Block release on breaking changes - if: steps.delimit.outputs.breaking_changes_detected == 'true' - run: | - echo "Breaking changes detected — semver bump: ${{ steps.delimit.outputs.semver_bump }}" - echo "Next version should be: ${{ steps.delimit.outputs.next_version }}" - exit 1 - -- name: Auto-tag on minor bump - if: steps.delimit.outputs.semver_bump == 'minor' - run: | - git tag "v${{ steps.delimit.outputs.next_version }}" -``` - ---- - -## Custom Policies - -Create `.delimit/policies.yml` in your repository root to define governance rules beyond the defaults. - -```yaml -# .delimit/policies.yml - -# Set to true to replace all default rules with only your custom rules. -# Default: false (custom rules merge with defaults). -override_defaults: false - -rules: - # Forbid removing endpoints without deprecation - - id: no_endpoint_removal - name: Forbid Endpoint Removal - change_types: - - endpoint_removed - severity: error # error | warning | info - action: forbid # forbid | allow | warn - message: "Endpoint {path} cannot be removed. Use deprecation headers instead." - - # Protect V1 API — no breaking changes allowed - - id: protect_v1_api - name: Protect V1 API - description: V1 endpoints are frozen - change_types: - - endpoint_removed - - method_removed - - field_removed - severity: error - action: forbid - conditions: - path_pattern: "^/v1/.*" - message: "V1 API is frozen. Changes must be made in V2." - - # Warn on type changes in 2xx responses - - id: warn_response_type_change - name: Warn Response Type Changes - change_types: - - type_changed - severity: warning - action: warn - conditions: - path_pattern: ".*:2\\d\\d.*" - message: "Type changed at {path} — verify client compatibility." - - # Allow adding enum values (informational) - - id: allow_enum_expansion - name: Allow Enum Expansion - change_types: - - enum_value_added - severity: info - action: allow - message: "Enum value added (non-breaking)." -``` - -### Available change types for rules - -| Change type | Breaking | Description | -|-------------|----------|-------------| -| `endpoint_removed` | Yes | An API endpoint path was removed | -| `method_removed` | Yes | An HTTP method was removed from an endpoint | -| `required_param_added` | Yes | A new required parameter was added | -| `param_removed` | Yes | A parameter was removed | -| `response_removed` | Yes | A response status code was removed | -| `required_field_added` | Yes | A new required field was added to a request body | -| `field_removed` | Yes | A field was removed from a response | -| `type_changed` | Yes | A field's type was changed (e.g., string to integer) | -| `format_changed` | Yes | A field's format was changed (e.g., date to date-time) | -| `enum_value_removed` | Yes | An allowed enum value was removed | -| `endpoint_added` | No | A new endpoint was added | -| `method_added` | No | A new HTTP method was added to an endpoint | -| `optional_param_added` | No | A new optional parameter was added | -| `response_added` | No | A new response status code was added | -| `optional_field_added` | No | A new optional field was added | -| `enum_value_added` | No | A new enum value was added | -| `description_changed` | No | A description was modified | - -### Default rules - -Delimit ships with 6 built-in rules that are always active unless you set `override_defaults: true`: - -1. **Forbid Endpoint Removal** — endpoints cannot be removed (error) -2. **Forbid Method Removal** — HTTP methods cannot be removed (error) -3. **Forbid Required Parameter Addition** — new required params break clients (error) -4. **Forbid Response Field Removal** — removing fields from 2xx responses (error) -5. **Warn on Type Changes** — type changes flagged as warnings -6. **Allow Enum Expansion** — adding enum values is always safe (info) - ---- - -## Slack / Discord Notifications - -Get notified in Slack or Discord when breaking API changes are detected. Add a `webhook_url` input pointing to your channel's incoming webhook: - -```yaml -- uses: delimit-ai/delimit-action@v1 - with: - spec: api/openapi.yaml - webhook_url: ${{ secrets.SLACK_WEBHOOK }} -``` - -The notification fires only when breaking changes are found. If the webhook URL is not set, this step is silently skipped. - -### Supported platforms - -| Platform | URL pattern | Payload format | -|----------|------------|----------------| -| **Slack** | `hooks.slack.com` | Block Kit with mrkdwn | -| **Discord** | `discord.com/api/webhooks` | Rich embed with color and fields | -| **Generic** | Anything else | Plain JSON event payload | - -Delimit auto-detects the platform from the URL and formats the message accordingly. Webhook failures are logged as warnings but never fail your CI run. - -### Discord example - -```yaml -- uses: delimit-ai/delimit-action@v1 - with: - spec: api/openapi.yaml - webhook_url: ${{ secrets.DISCORD_WEBHOOK }} -``` - -### Generic webhook - -Any URL that is not Slack or Discord receives a JSON payload: - -```json -{ - "event": "breaking_changes_detected", - "repo": "org/repo", - "pr_number": 123, - "pr_title": "Update user endpoints", - "breaking_changes": 3, - "additive_changes": 1, - "semver": "MAJOR", - "pr_url": "https://github.com/org/repo/pull/123" -} -``` - ---- - -## Advisory vs Enforce Mode - -| Behavior | `advisory` (default) | `enforce` | -|----------|---------------------|-----------| -| PR comment | Yes | Yes | -| GitHub annotations | Yes | Yes | -| Fails CI on breaking changes | **No** | **Yes** | -| Exit code on violations | `0` | `1` | - -**Start with advisory mode.** It gives your team visibility into API changes without blocking merges. Once your team is comfortable, switch to `enforce` to gate deployments. - -```yaml -# Advisory — non-blocking (default) -- uses: delimit-ai/delimit-action@v1 - with: - old_spec: base/api/openapi.yaml - new_spec: api/openapi.yaml - mode: advisory - -# Enforce — blocks merge on breaking changes -- uses: delimit-ai/delimit-action@v1 - with: - old_spec: base/api/openapi.yaml - new_spec: api/openapi.yaml - mode: enforce -``` - ---- - -## Examples - -### Advisory mode (recommended starting point) - -```yaml -name: API Check -on: pull_request - -jobs: - api-check: - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: base - - uses: delimit-ai/delimit-action@v1 - with: - old_spec: base/api/openapi.yaml - new_spec: api/openapi.yaml -``` - -### Enforce mode - -```yaml -name: API Governance -on: pull_request - -jobs: - api-check: - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: base - - uses: delimit-ai/delimit-action@v1 - with: - old_spec: base/api/openapi.yaml - new_spec: api/openapi.yaml - mode: enforce -``` - -### Custom policy file - -```yaml -- uses: delimit-ai/delimit-action@v1 - with: - old_spec: base/api/openapi.yaml - new_spec: api/openapi.yaml - mode: enforce - policy_file: .delimit/policies.yml -``` - -### Using outputs to control downstream jobs - -```yaml -jobs: - api-check: - runs-on: ubuntu-latest - outputs: - breaking: ${{ steps.delimit.outputs.breaking_changes_detected }} - bump: ${{ steps.delimit.outputs.semver_bump }} - next_version: ${{ steps.delimit.outputs.next_version }} - steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: base - - uses: delimit-ai/delimit-action@v1 - id: delimit - with: - old_spec: base/api/openapi.yaml - new_spec: api/openapi.yaml - - deploy: - needs: api-check - if: needs.api-check.outputs.breaking != 'true' - runs-on: ubuntu-latest - steps: - - run: echo "Safe to deploy — next version ${{ needs.api-check.outputs.next_version }}" -``` - ---- - -## Supported Formats - -- OpenAPI 3.0 and 3.1 -- Swagger 2.0 -- YAML and JSON spec files - ---- - -## What the PR Comment Looks Like - -When Delimit detects changes, it posts (or updates) a comment on your pull request: - -``` -## Delimit: Breaking Changes `MAJOR` - -| Metric | Value | -|--------|-------| -| Semver bump | `major` | -| Next version | `2.0.0` | -| Total changes | 5 | -| Breaking | 2 | -| Violations | 2 | - -### Violations - -| Severity | Rule | Description | Location | -|----------|------|-------------|----------| -| Error | Forbid Endpoint Removal | Endpoint /users/{id} cannot be removed | `/users/{id}` | -| Warning | Warn on Type Changes | Type changed from string to integer | `/users:200.age` | - -
-Migration guide -...step-by-step instructions for each breaking change... -
-``` - -The comment is automatically updated on each push to the PR branch. No duplicate comments. - ---- - -## FAQ / Troubleshooting - -### Delimit skipped validation and did nothing - -Both `old_spec` and `new_spec` must be provided. If either is empty, Delimit exits cleanly with no output. Make sure both paths point to valid spec files. - -### How do I get the base branch spec? - -Use a second `actions/checkout` step to check out the base branch into a subdirectory: - -```yaml -- uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: base -``` - -Then reference `base/path/to/openapi.yaml` as `old_spec`. - -### My spec file is not found - -Verify the path relative to the repository root. Common locations: -- `api/openapi.yaml` -- `docs/openapi.yaml` -- `openapi.yaml` -- `swagger.json` - -### The action posts duplicate PR comments - -Delimit searches for an existing comment containing "Delimit" from a bot user and updates it in place. If you see duplicates, ensure `github_token` has `pull-requests: write` permission. - -### Can I use this with JSON specs? - -Yes. Delimit supports both YAML (`.yaml`, `.yml`) and JSON (`.json`) spec files. Set the input paths accordingly. - -### Can I use this in a monorepo with multiple specs? - -Yes. Add multiple Delimit steps, each with different `old_spec` / `new_spec` pairs: - -```yaml -- uses: delimit-ai/delimit-action@v1 - with: - old_spec: base/services/users/openapi.yaml - new_spec: services/users/openapi.yaml - -- uses: delimit-ai/delimit-action@v1 - with: - old_spec: base/services/billing/openapi.yaml - new_spec: services/billing/openapi.yaml -``` - -### Advisory mode still shows errors in the PR comment — is that expected? - -Yes. Advisory mode reports everything (including errors) in the PR comment and GitHub annotations, but it always exits with code `0` so your CI stays green. Switch to `enforce` mode when you want breaking changes to block the merge. - -### How is the semver bump calculated? - -The classification is deterministic: -- **major** — any breaking change detected (endpoint removed, required param added, field removed, type changed, etc.) -- **minor** — additive changes only (new endpoints, optional fields, enum values added) -- **patch** — non-functional changes only (description updates) -- **none** — no changes detected - ---- - -## CLI - -For local development, pre-commit checks, and CI/CD pipelines outside GitHub Actions, use the [Delimit CLI](https://www.npmjs.com/package/delimit-cli): - -```bash -npm install -g delimit-cli -delimit lint api/openapi.yaml -delimit diff old-api.yaml new-api.yaml -delimit explain old-api.yaml new-api.yaml --template migration -``` - ---- - -## Links - -- [Delimit CLI on npm](https://www.npmjs.com/package/delimit-cli) — Local development tool -- [GitHub Repository](https://github.com/delimit-ai/delimit) — Source code and issues -- [GitHub Action Marketplace](https://github.com/marketplace/actions/delimit-api-governance) — Install from Marketplace - +# `` Delimit GitHub Action\n\n**Catch breaking API changes before merge** — semver classification, migration guides, and policy enforcement for OpenAPI specs.\n\n --- -## License +## Think and Build -MIT +Delimit is more than just an action — it is a governance layer for your AI coding assistants. When you use the Delimit Swarm, you can say **"Think and Build"** to your agents, and they will automatically use this action to verify their own code changes against your API policies before requesting a merge. +\n[![GitHub Marketplace](https://img.shields.io/badge/Marketplace-Delimit-blue)](https://github.com/marketplace/actions/delimit-api-governance)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)\n[![API Governance](https://delimit-ai.github.io/badge/pass.svg)](https://github.com/marketplace/actions/delimit-api-governance)\n\nDelimit runs on every pull request, compares your OpenAPI spec against the base branch, and posts a detailed comment with breaking changes, semver classification, policy violations, and migration guidance. No API keys, no external services, no config required to get started.\n\n## What it looks like\n\n

\n Delimit PR comment showing breaking changes\n

\n\n---\n\n## Features\n\n- **Breaking change detection** — catches 27 types of changes (17 breaking, 10 non-breaking) across endpoints, parameters, response schemas, types, enums, security, and constraints\n- **Semver classification** — deterministic `major` / `minor` / `patch` / `none` bump recommendation with computed next version\n- **Migration guides** — auto-generated step-by-step migration instructions for every breaking change\n- **PR comments** — rich Markdown summary posted directly on your pull request, updated on each push\n- **Advisory and enforce modes** — start with non-blocking warnings, promote to CI-gating when ready\n- **Custom policies** — define your own governance rules in `.delimit/policies.yml` with path patterns, severity levels, and custom messages\n- **7 explainer templates** — developer, team lead, product, migration, changelog, PR comment, and Slack formats\n\n---\n\n## Quick Start\n\nAdd this file to `.github/workflows/api-check.yml`:\n\n```yaml\nname: API Contract Check\non: pull_request\n\njobs:\n delimit:\n runs-on: ubuntu-latest\n permissions:\n pull-requests: write\n steps:\n - uses: actions/checkout@v4\n - uses: delimit-ai/delimit-action@v1\n with:\n spec: api/openapi.yaml\n```\n\nThat is it. Delimit auto-fetches the base branch version of your spec and diffs it against the PR changes. Runs in **advisory mode** by default — posts a PR comment but never fails your build.\n\n### What the PR comment looks like\n\nWhen Delimit detects breaking changes, it posts a comment like this:\n\n> **Delimit API Governance** | Breaking Changes Detected\n>\n> | Change | Path | Severity |\n> |--------|------|----------|\n> | endpoint_removed | `DELETE /pets/{petId}` | error |\n> | type_changed | `/pets:GET:200[].id` (string → integer) | warning |\n> | enum_value_removed | `/pets:GET:200[].status` | warning |\n>\n> **Semver**: MAJOR (1.0.0 → 2.0.0)\n>\n>
Migration Guide (3 steps)\n>\n> **Step 1**: `DELETE /pets/{petId}` was removed. Update clients to use an alternative endpoint or remove calls to this path.\n>\n> **Step 2**: `id` changed from `string` to `integer`. Update serialization logic, type assertions, and database column types.\n>\n> **Step 3**: `status` enum value `"pending"` was removed. Update clients to stop sending this value.\n>\n>
\n\nSee the [live demo](https://github.com/delimit-ai/delimit-action-demo/pull/2) — a Users API migration with 23 breaking changes detected across 27 change types, severity badges, and a migration guide.\n\n### Advanced: explicit base and head specs\n\nIf you need to compare specific files (e.g., pre-checked-out base branch), use `old_spec` and `new_spec` instead:\n\n```yaml\n - uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n```\n\n---\n\n## Full Usage\n\n```yaml\nname: API Governance\non: pull_request\n\njobs:\n api-check:\n runs-on: ubuntu-latest\n permissions:\n pull-requests: write\n steps:\n - uses: actions/checkout@v4\n\n - name: Checkout base spec\n uses: actions/checkout@v4\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: base\n\n - uses: delimit-ai/delimit-action@v1\n id: delimit\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n mode: enforce\n policy_file: .delimit/policies.yml\n github_token: ${{ secrets.GITHUB_TOKEN }}\n\n - name: Use outputs\n if: always()\n run: |\n echo "Breaking changes: ${{ steps.delimit.outputs.breaking_changes_detected }}"\n echo "Violations: ${{ steps.delimit.outputs.violations_count }}"\n echo "Semver bump: ${{ steps.delimit.outputs.semver_bump }}"\n echo "Next version: ${{ steps.delimit.outputs.next_version }}"\n```\n\n---\n\n## Inputs\n\n| Input | Required | Default | Description |\n|-------|----------|---------|-------------|\n| `spec` | No | `''` | Path to the changed OpenAPI spec. On pull requests, Delimit auto-fetches the base branch version for comparison. |\n| `old_spec` | No | `''` | Path to the old/base API specification file. |\n| `new_spec` | No | `''` | Path to the new/changed API specification file. |\n| `mode` | No | `advisory` | `advisory` (comments only) or `enforce` (fails CI on breaking changes). |\n| `github_token` | No | `${{ github.token }}` | GitHub token used to post PR comments. |\n| `policy_file` | No | `''` | Path to a custom policy file (e.g., `.delimit/policies.yml`). |\n\n> **Note**: Provide either `spec` for pull request workflows, or both `old_spec` and `new_spec` for explicit comparisons. If neither form is provided, the action exits with an error.\n\n---\n\n## Outputs\n\n| Output | Type | Description |\n|--------|------|-------------|\n| `breaking_changes_detected` | `string` | `"true"` if any breaking change was found, `"false"` otherwise. |\n| `violations_count` | `string` | Number of policy violations (errors + warnings). |\n| `semver_bump` | `string` | Recommended version bump: `major`, `minor`, `patch`, or `none`. |\n| `next_version` | `string` | Computed next version string (e.g., `2.0.0`). |\n| `report` | `string` | Full JSON report of all detected changes, violations, and semver data. |\n\n### Using outputs in subsequent steps\n\n```yaml\n- uses: delimit-ai/delimit-action@v1\n id: delimit\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n\n- name: Block release on breaking changes\n if: steps.delimit.outputs.breaking_changes_detected == 'true'\n run: |\n echo "Breaking changes detected — semver bump: ${{ steps.delimit.outputs.semver_bump }}"\n echo "Next version should be: ${{ steps.delimit.outputs.next_version }}"\n exit 1\n\n- name: Auto-tag on minor bump\n if: steps.delimit.outputs.semver_bump == 'minor'\n run: |\n git tag "v${{ steps.delimit.outputs.next_version }}"\n```\n\n---\n\n## Custom Policies\n\nCreate `.delimit/policies.yml` in your repository root to define governance rules beyond the defaults.\n\n```yaml\n# .delimit/policies.yml\n\n# Set to true to replace all default rules with only your custom rules.\n# Default: false (custom rules merge with defaults).\noverride_defaults: false\n\nrules:\n # Forbid removing endpoints without deprecation\n - id: no_endpoint_removal\n name: Forbid Endpoint Removal\n change_types:\n - endpoint_removed\n severity: error # error | warning | info\n action: forbid # forbid | allow | warn\n message: "Endpoint {path} cannot be removed. Use deprecation headers instead."\n\n # Protect V1 API — no breaking changes allowed\n - id: protect_v1_api\n name: Protect V1 API\n description: V1 endpoints are frozen\n change_types:\n - endpoint_removed\n - method_removed\n - field_removed\n severity: error\n action: forbid\n conditions:\n path_pattern: "^/v1/.*"\n message: "V1 API is frozen. Changes must be made in V2."\n\n # Warn on type changes in 2xx responses\n - id: warn_response_type_change\n name: Warn Response Type Changes\n change_types:\n - type_changed\n severity: warning\n action: warn\n conditions:\n path_pattern: ".*:2\\d\\d.*"\n message: "Type changed at {path} — verify client compatibility."\n\n # Allow adding enum values (informational)\n - id: allow_enum_expansion\n name: Allow Enum Expansion\n change_types:\n - enum_value_added\n severity: info\n action: allow\n message: "Enum value added (non-breaking)."\n```\n\n### Available change types for rules\n\n| Change type | Breaking | Description |\n|-------------|----------|-------------|\n| `endpoint_removed` | Yes | An API endpoint path was removed |\n| `method_removed` | Yes | An HTTP method was removed from an endpoint |\n| `required_param_added` | Yes | A new required parameter was added |\n| `param_removed` | Yes | A parameter was removed |\n| `response_removed` | Yes | A response status code was removed |\n| `required_field_added` | Yes | A new required field was added to a request body |\n| `field_removed` | Yes | A field was removed from a response |\n| `type_changed` | Yes | A field's type was changed (e.g., string to integer) |\n| `format_changed` | Yes | A field's format was changed (e.g., date to date-time) |\n| `enum_value_removed` | Yes | An allowed enum value was removed |\n| `endpoint_added` | No | A new endpoint was added |\n| `method_added` | No | A new HTTP method was added to an endpoint |\n| `optional_param_added` | No | A new optional parameter was added |\n| `response_added` | No | A new response status code was added |\n| `optional_field_added` | No | A new optional field was added |\n| `enum_value_added` | No | A new enum value was added |\n| `description_changed` | No | A description was modified |\n\n### Default rules\n\nDelimit ships with 6 built-in rules that are always active unless you set `override_defaults: true`:\n\n1. **Forbid Endpoint Removal** — endpoints cannot be removed (error)\n2. **Forbid Method Removal** — HTTP methods cannot be removed (error)\n3. **Forbid Required Parameter Addition** — new required params break clients (error)\n4. **Forbid Response Field Removal** — removing fields from 2xx responses (error)\n5. **Warn on Type Changes** — type changes flagged as warnings\n6. **Allow Enum Expansion** — adding enum values is always safe (info)\n\n---\n\n## Slack / Discord Notifications\n\nGet notified in Slack or Discord when breaking API changes are detected. Add a `webhook_url` input pointing to your channel's incoming webhook:\n\n```yaml\n- uses: delimit-ai/delimit-action@v1\n with:\n spec: api/openapi.yaml\n webhook_url: ${{ secrets.SLACK_WEBHOOK }}\n```\n\nThe notification fires only when breaking changes are found. If the webhook URL is not set, this step is silently skipped.\n\n### Supported platforms\n\n| Platform | URL pattern | Payload format |\n|----------|------------|----------------|\n| **Slack** | `hooks.slack.com` | Block Kit with mrkdwn |\n| **Discord** | `discord.com/api/webhooks` | Rich embed with color and fields |\n| **Generic** | Anything else | Plain JSON event payload |\n\nDelimit auto-detects the platform from the URL and formats the message accordingly. Webhook failures are logged as warnings but never fail your CI run.\n\n### Discord example\n\n```yaml\n- uses: delimit-ai/delimit-action@v1\n with:\n spec: api/openapi.yaml\n webhook_url: ${{ secrets.DISCORD_WEBHOOK }}\n```\n\n### Generic webhook\n\nAny URL that is not Slack or Discord receives a JSON payload:\n\n```json\n{\n "event": "breaking_changes_detected",\n "repo": "org/repo",\n "pr_number": 123,\n "pr_title": "Update user endpoints",\n "breaking_changes": 3,\n "additive_changes": 1,\n "semver": "MAJOR",\n "pr_url": "https://github.com/org/repo/pull/123"\n}\n```\n\n---\n\n## Advisory vs Enforce Mode\n\n| Behavior | `advisory` (default) | `enforce` |\n|----------|---------------------|-----------|\n| PR comment | Yes | Yes |\n| GitHub annotations | Yes | Yes |\n| Fails CI on breaking changes | **No** | **Yes** |\n| Exit code on violations | `0` | `1` |\n\n**Start with advisory mode.** It gives your team visibility into API changes without blocking merges. Once your team is comfortable, switch to `enforce` to gate deployments.\n\n```yaml\n# Advisory — non-blocking (default)\n- uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n mode: advisory\n\n# Enforce — blocks merge on breaking changes\n- uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n mode: enforce\n```\n\n---\n\n## Examples\n\n### Advisory mode (recommended starting point)\n\n```yaml\nname: API Check\non: pull_request\n\njobs:\n api-check:\n runs-on: ubuntu-latest\n permissions:\n pull-requests: write\n steps:\n - uses: actions/checkout@v4\n - uses: actions/checkout@v4\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: base\n - uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n```\n\n### Enforce mode\n\n```yaml\nname: API Governance\non: pull_request\n\njobs:\n api-check:\n runs-on: ubuntu-latest\n permissions:\n pull-requests: write\n steps:\n - uses: actions/checkout@v4\n - uses: actions/checkout@v4\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: base\n - uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n mode: enforce\n```\n\n### Custom policy file\n\n```yaml\n- uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n mode: enforce\n policy_file: .delimit/policies.yml\n```\n\n### Using outputs to control downstream jobs\n\n```yaml\njobs:\n api-check:\n runs-on: ubuntu-latest\n outputs:\n breaking: ${{ steps.delimit.outputs.breaking_changes_detected }}\n bump: ${{ steps.delimit.outputs.semver_bump }}\n next_version: ${{ steps.delimit.outputs.next_version }}\n steps:\n - uses: actions/checkout@v4\n - uses: actions/checkout@v4\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: base\n - uses: delimit-ai/delimit-action@v1\n id: delimit\n with:\n old_spec: base/api/openapi.yaml\n new_spec: api/openapi.yaml\n\n deploy:\n needs: api-check\n if: needs.api-check.outputs.breaking != 'true'\n runs-on: ubuntu-latest\n steps:\n - run: echo "Safe to deploy — next version ${{ needs.api-check.outputs.next_version }}"\n```\n\n---\n\n## Supported Formats\n\n- OpenAPI 3.0 and 3.1\n- Swagger 2.0\n- YAML and JSON spec files\n\n---\n\n## What the PR Comment Looks Like\n\nWhen Delimit detects changes, it posts (or updates) a comment on your pull request:\n\n```\n## Delimit: Breaking Changes `MAJOR`\n\n| Metric | Value |\n|--------|-------|\n| Semver bump | `major` |\n| Next version | `2.0.0` |\n| Total changes | 5 |\n| Breaking | 2 |\n| Violations | 2 |\n\n### Violations\n\n| Severity | Rule | Description | Location |\n|----------|------|-------------|----------|\n| Error | Forbid Endpoint Removal | Endpoint /users/{id} cannot be removed | `/users/{id}` |\n| Warning | Warn on Type Changes | Type changed from string to integer | `/users:200.age` |\n\n
\nMigration guide\n...step-by-step instructions for each breaking change...\n
\n```\n\nThe comment is automatically updated on each push to the PR branch. No duplicate comments.\n\n---\n\n## FAQ / Troubleshooting\n\n### Delimit skipped validation and did nothing\n\nBoth `old_spec` and `new_spec` must be provided. If either is empty, Delimit exits cleanly with no output. Make sure both paths point to valid spec files.\n\n### How do I get the base branch spec?\n\nUse a second `actions/checkout` step to check out the base branch into a subdirectory:\n\n```yaml\n- uses: actions/checkout@v4\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: base\n```\n\nThen reference `base/path/to/openapi.yaml` as `old_spec`.\n\n### My spec file is not found\n\nVerify the path relative to the repository root. Common locations:\n- `api/openapi.yaml`\n- `docs/openapi.yaml`\n- `openapi.yaml`\n- `swagger.json`\n\n### The action posts duplicate PR comments\n\nDelimit searches for an existing comment containing "Delimit" from a bot user and updates it in place. If you see duplicates, ensure `github_token` has `pull-requests: write` permission.\n\n### Can I use this with JSON specs?\n\nYes. Delimit supports both YAML (`.yaml`, `.yml`) and JSON (`.json`) spec files. Set the input paths accordingly.\n\n### Can I use this in a monorepo with multiple specs?\n\nYes. Add multiple Delimit steps, each with different `old_spec` / `new_spec` pairs:\n\n```yaml\n- uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/services/users/openapi.yaml\n new_spec: services/users/openapi.yaml\n\n- uses: delimit-ai/delimit-action@v1\n with:\n old_spec: base/services/billing/openapi.yaml\n new_spec: services/billing/openapi.yaml\n```\n\n### Advisory mode still shows errors in the PR comment — is that expected?\n\nYes. Advisory mode reports everything (including errors) in the PR comment and GitHub annotations, but it always exits with code `0` so your CI stays green. Switch to `enforce` mode when you want breaking changes to block the merge.\n\n### How is the semver bump calculated?\n\nThe classification is deterministic:\n- **major** — any breaking change detected (endpoint removed, required param added, field removed, type changed, etc.)\n- **minor** — additive changes only (new endpoints, optional fields, enum values added)\n- **patch** — non-functional changes only (description updates)\n- **none** — no changes detected\n\n---\n\n## CLI\n\nFor local development, pre-commit checks, and CI/CD pipelines outside GitHub Actions, use the [Delimit CLI](https://www.npmjs.com/package/delimit-cli):\n\n```bash\nnpm install -g delimit-cli\ndelimit lint api/openapi.yaml\ndelimit diff old-api.yaml new-api.yaml\ndelimit explain old-api.yaml new-api.yaml --template migration\n```\n\n---\n\n## Links\n\n- [Delimit CLI on npm](https://www.npmjs.com/package/delimit-cli) — Local development tool\n- [GitHub Repository](https://github.com/delimit-ai/delimit) — Source code and issues\n- [GitHub Action Marketplace](https://github.com/marketplace/actions/delimit-api-governance) — Install from Marketplace\n\n---\n\n## License\n\nMIT \ No newline at end of file diff --git a/action.yml b/action.yml index 2879723..f90d2ad 100644 --- a/action.yml +++ b/action.yml @@ -243,18 +243,33 @@ runs: return { icon: '\u{1F7E1}', label: 'Medium' }; }; + // Teaching: explain WHY each rule matters to existing consumers + const teachingOf = (rule) => { + const teachings = { + no_endpoint_removal: 'Removing an endpoint is a breaking change because existing clients are actively calling it. Their requests will start returning 404 errors.', + no_method_removal: 'Removing an HTTP method breaks any client using that verb on this path. Existing integrations will receive 405 Method Not Allowed.', + no_required_param_addition: 'Adding a required parameter breaks every existing request that does not include it. Existing clients will start getting 400 Bad Request.', + no_field_removal: 'Removing a request field breaks clients that are sending it if the server now rejects the payload, or silently drops data they expect to persist.', + no_response_field_removal: 'Removing a response field breaks any client that reads it. Their code will encounter undefined/null where it expects a value.', + no_type_changes: 'Changing a field type breaks serialization. Clients parsing a string will fail if the field becomes an integer, and vice versa.', + warn_type_change: 'Changing a field type breaks serialization. Clients parsing the old type will fail to deserialize the new one.', + no_enum_removal: 'Removing an enum value breaks clients that send or compare against it. Their validation or switch/case logic will fail.', + }; + return teachings[rule] || null; + }; + const errors = violations.filter(v => v.severity === 'error'); const warnings = violations.filter(v => v.severity === 'warning'); let body = ''; if (bc === 0) { - // ── GREEN PATH: no breaking changes ── + // ── GREEN PATH: Governance Passed ── const semverLabel = bump === 'minor' ? 'MINOR' : bump === 'patch' ? 'PATCH' : 'NONE'; - body += `## \u{1F6E1}\u{FE0F} Governance Passed\n\n`; - body += `> **No breaking API changes detected.**`; + body += `## \u{2705} Governance Passed\n\n`; + body += `> **No breaking API changes detected.** This PR is safe for existing consumers.`; if (total > 0) { - body += ` ${additive} additive change${additive !== 1 ? 's' : ''} found \u{2014} Semver: **${semverLabel}**`; + body += `\n> ${additive} additive change${additive !== 1 ? 's' : ''} found \u{2014} Semver: **${semverLabel}**`; } body += `\n\n`; @@ -266,65 +281,56 @@ runs: body += `\n\n\n`; } } else { - // ── RED PATH: breaking changes detected ── - body += `## \u{1F6E1}\u{FE0F} Breaking API Changes Detected\n\n`; + // ── RED PATH: Governance Failed ── + body += `## \u{274C} Governance Failed\n\n`; // Summary card const parts = []; - parts.push(`\u{1F534} **${bc} breaking change${bc !== 1 ? 's' : ''}**`); + parts.push(`**${bc} breaking change${bc !== 1 ? 's' : ''}** detected`); parts.push(`Semver: **MAJOR**`); if (semver.next_version) parts.push(`Next: \`${semver.next_version}\``); - if (mode === 'enforce') parts.push(`\u{1F6A8} **CI blocked**`); + if (mode === 'enforce') parts.push(`**CI blocked**`); body += `> ${parts.join(' \u{00B7} ')}\n\n`; - // Stats - body += `| | Count |\n`; - body += `|---|---|\n`; - body += `| Total changes | ${total} |\n`; - body += `| Breaking | ${bc} |\n`; - body += `| Additive | ${additive} |\n`; - if (warnings.length > 0) body += `| Warnings | ${warnings.length} |\n`; - body += `\n`; - - // Breaking changes table + // Breaking changes: catch + teach for each if (errors.length || warnings.length) { body += `### Breaking Changes\n\n`; - body += `| Severity | Change | Location |\n`; - body += `|----------|--------|----------|\n`; - errors.forEach(v => { + errors.forEach((v, i) => { const s = severityOf(v.rule); - body += `| ${s.icon} ${s.label} | ${v.message} | \`${v.path}\` |\n`; + body += `${s.icon} **${s.label}** \u{2014} \`${v.path}\`\n`; + body += `${v.message}\n`; + const teaching = teachingOf(v.rule); + if (teaching) { + body += `> **Why this breaks:** ${teaching}\n`; + } + body += `\n`; }); warnings.forEach(v => { - body += `| \u{1F7E1} Warning | ${v.message} | \`${v.path}\` |\n`; - }); - body += `\n`; - } - - // Policy violations (separate section when there are warnings alongside errors) - if (warnings.length > 0 && errors.length > 0) { - body += `
\n\u{26A0}\u{FE0F} Policy warnings (${warnings.length})\n\n`; - warnings.forEach(v => { - body += `- \u{1F7E1} \`${v.path}\` \u{2014} ${v.message}\n`; + body += `\u{1F7E1} **Warning** \u{2014} \`${v.path}\`\n`; + body += `${v.message}\n`; + const teaching = teachingOf(v.rule); + if (teaching) { + body += `> **Why this breaks:** ${teaching}\n`; + } + body += `\n`; }); - body += `\n
\n\n`; } - // Migration guide + // Migration guide (collapsed) if (migration && decision === 'fail') { body += `
\n\u{1F4CB} Migration guide\n\n`; body += migration + '\n\n
\n\n'; } else if (errors.length > 0) { - body += `
\n\u{1F4CB} Migration guide\n\n`; + body += `
\n\u{1F4CB} How to fix\n\n`; const hints = { - no_endpoint_removal: 'Consumers must stop calling this endpoint. Consider a deprecation period before removal.', - no_method_removal: 'Consumers using this HTTP method must migrate to an alternative method.', - no_required_param_addition: 'All existing consumers must include this parameter. Consider making it optional with a default value.', - no_field_removal: 'Consumers reading this field will break. Add it back or provide a migration path.', - no_response_field_removal: 'Consumers reading this field will break. Add it back or provide a migration path.', - no_type_changes: 'Consumers expecting the old type will fail to parse. Coordinate the type migration.', - warn_type_change: 'Consumers expecting the old type will fail to parse. Coordinate the type migration.', - no_enum_removal: 'Consumers using this value must update. Consider keeping it as deprecated.', + no_endpoint_removal: 'Deprecate the endpoint first, then remove it in a future major version. This gives consumers time to migrate.', + no_method_removal: 'Keep the old method available or redirect it. Remove only after a deprecation period.', + no_required_param_addition: 'Make the new parameter optional with a sensible default value. This keeps existing requests working.', + no_field_removal: 'Keep the field in the schema. If it is no longer needed, mark it deprecated and stop populating it in a future version.', + no_response_field_removal: 'Restore the field in the response. If removing it is intentional, version the endpoint (e.g., /v2/) so existing consumers are unaffected.', + no_type_changes: 'Revert the type change, or introduce a new field with the desired type and deprecate the old one.', + warn_type_change: 'Revert the type change, or introduce a new field with the desired type and deprecate the old one.', + no_enum_removal: 'Keep the enum value and mark it deprecated. Remove it only in a coordinated major release.', }; errors.forEach((v, i) => { body += `**${i + 1}. \`${v.path}\`**\n`; @@ -336,44 +342,40 @@ runs: // Additive changes (collapsed) const safeChanges = allChanges.filter(c => !c.is_breaking); if (safeChanges.length > 0) { - body += `
\n\u{2705} New additions (${safeChanges.length})\n\n`; + body += `
\n\u{2705} Non-breaking additions (${safeChanges.length})\n\n`; safeChanges.forEach(c => body += `- \`${c.path || ''}\` \u{2014} ${c.message}\n`); body += `\n
\n\n`; } - - // Local run hint - body += `> **Fix locally:** \`npx delimit-cli lint\`\n\n`; } - // Governance Gates (LED-259: PR-native governance copilot) + // Governance Gates body += `### Governance Gates\n\n`; - body += `| Gate | Status | Chain |\n`; - body += `|------|--------|-------|\n`; + body += `| Gate | Status |\n`; + body += `|------|--------|\n`; const lintPass = bc === 0; const policyPass = violations.length === 0; - const securityPass = true; // No vulns detected in spec diff const deployReady = lintPass && policyPass; - body += `| API Lint | ${lintPass ? '\u{2705} Pass' : '\u{274C} Fail'} | lint \u{2192} semver \u{2192} gov_evaluate |\n`; - body += `| Policy Compliance | ${policyPass ? '\u{2705} Pass' : '\u{274C} ' + violations.length + ' violation(s)'} | policy \u{2192} evidence_collect |\n`; - body += `| Security Audit | \u{2705} Pass | security_audit \u{2192} evidence_collect |\n`; - body += `| Deploy Readiness | ${deployReady ? '\u{2705} Ready' : '\u{26D4} Blocked'} | deploy_plan \u{2192} security_audit |\n`; + body += `| API Lint | ${lintPass ? '\u{2705} Pass' : '\u{274C} Fail'} |\n`; + body += `| Policy Compliance | ${policyPass ? '\u{2705} Pass' : '\u{274C} ' + violations.length + ' violation(s)'} |\n`; + body += `| Security Audit | \u{2705} Pass |\n`; + body += `| Deploy Readiness | ${deployReady ? '\u{2705} Ready' : '\u{26D4} Blocked'} |\n`; body += `\n`; - // Enforcement chain visualization if (!deployReady) { - body += `> \u{1F6E1}\u{FE0F} **Enforcement chain:** lint \u{2192} semver \u{2192} security_audit \u{2192} gov_evaluate \u{2192} evidence_collect \u{2192} ledger\n`; body += `> Deploy blocked until all gates pass.`; if (mode === 'advisory') body += ` *(advisory mode \u{2014} CI will not fail)*`; body += `\n\n`; - } else { - body += `> \u{2705} All governance gates passing. Safe to merge.\n\n`; } - // Footer + // Reproduce locally (always shown) body += `---\n`; - body += `Powered by [Delimit](https://delimit.ai) \u{00B7} [Docs](https://delimit.ai/docs) \u{00B7} [Install](https://github.com/marketplace/actions/delimit-api-governance)`; + body += `**Run locally:**\n`; + body += `\`\`\`\nnpx delimit-cli lint path/to/openapi.yaml\n\`\`\`\n\n`; + + // Footer + body += `Powered by [Delimit](https://github.com/delimit-ai/delimit-action) \u{2014} API governance for every PR`; // Find existing comment to update const { data: comments } = await github.rest.issues.listComments({ diff --git a/core/ci_formatter.py b/core/ci_formatter.py index 645e185..b59a257 100644 --- a/core/ci_formatter.py +++ b/core/ci_formatter.py @@ -89,11 +89,47 @@ def _format_text(self, result: Dict[str, Any]) -> str: return "\n".join(lines) + # Teaching: explain WHY each rule matters to existing consumers + TEACHINGS = { + "no_endpoint_removal": "Removing an endpoint is a breaking change because existing clients are actively calling it. Their requests will start returning 404 errors.", + "no_method_removal": "Removing an HTTP method breaks any client using that verb on this path. Existing integrations will receive 405 Method Not Allowed.", + "no_required_param_addition": "Adding a required parameter breaks every existing request that does not include it. Existing clients will start getting 400 Bad Request.", + "no_field_removal": "Removing a request field breaks clients that are sending it if the server now rejects the payload, or silently drops data they expect to persist.", + "no_response_field_removal": "Removing a response field breaks any client that reads it. Their code will encounter undefined/null where it expects a value.", + "no_type_changes": "Changing a field type breaks serialization. Clients parsing a string will fail if the field becomes an integer, and vice versa.", + "warn_type_change": "Changing a field type breaks serialization. Clients parsing the old type will fail to deserialize the new one.", + "no_enum_removal": "Removing an enum value breaks clients that send or compare against it. Their validation or switch/case logic will fail.", + } + + # Migration hints: how to FIX each rule + FIX_HINTS = { + "no_endpoint_removal": "Deprecate the endpoint first, then remove it in a future major version. This gives consumers time to migrate.", + "no_method_removal": "Keep the old method available or redirect it. Remove only after a deprecation period.", + "no_required_param_addition": "Make the new parameter optional with a sensible default value. This keeps existing requests working.", + "no_field_removal": "Keep the field in the schema. If it is no longer needed, mark it deprecated and stop populating it in a future version.", + "no_response_field_removal": "Restore the field in the response. If removing it is intentional, version the endpoint (e.g., /v2/) so existing consumers are unaffected.", + "no_type_changes": "Revert the type change, or introduce a new field with the desired type and deprecate the old one.", + "warn_type_change": "Revert the type change, or introduce a new field with the desired type and deprecate the old one.", + "no_enum_removal": "Keep the enum value and mark it deprecated. Remove it only in a coordinated major release.", + } + + def _severity_of(self, rule: str) -> tuple: + """Return (icon, label) for a policy rule.""" + critical = {"no_endpoint_removal", "no_method_removal", "no_field_removal", "no_response_field_removal"} + high = {"no_required_param_addition", "warn_type_change", "no_type_changes", "no_enum_removal"} + if rule in critical: + return ("\U0001f534", "Critical") + if rule in high: + return ("\U0001f7e0", "High") + return ("\U0001f7e1", "Medium") + def _format_markdown(self, result: Dict[str, Any]) -> str: """Format as Markdown for PR comments. - Includes semver classification badge and migration guidance when - the result carries semver/explainer data. + Follows the catch + teach + invite pattern: + - Catch: surface the breaking change + - Teach: explain WHY it breaks existing consumers + - Invite: show how to run locally + CTA """ lines = [] @@ -112,19 +148,18 @@ def _format_markdown(self, result: Dict[str, Any]) -> str: warnings = [v for v in violations if v.get("severity") == "warning"] if bc == 0: - # ── GREEN PATH ── + # ── GREEN PATH: Governance Passed ── bump_label = "NONE" if semver: bump_label = semver.get("bump", "none").upper() - lines.append("\U0001f6e1\ufe0f **Governance Passed**\n") + lines.append("\u2705 **Governance Passed**\n") + lines.append("> **No breaking API changes detected.** This PR is safe for existing consumers.") if total > 0: lines.append( - f"> **No breaking API changes detected.** " - f"{additive} additive change{'s' if additive != 1 else ''} " - f"found \u2014 Semver: **{bump_label}**\n" + f"> {additive} additive change{'s' if additive != 1 else ''} " + f"found \u2014 Semver: **{bump_label}**" ) - else: - lines.append("> **No breaking API changes detected.**\n") + lines.append("") # Additive changes safe_changes = [c for c in all_changes if not c.get("is_breaking")] @@ -135,83 +170,104 @@ def _format_markdown(self, result: Dict[str, Any]) -> str: lines.append(f"- `{c.get('path', '')}` \u2014 {c.get('message', '')}") lines.append("
\n") else: - # ── RED PATH ── - lines.append("\U0001f6e1\ufe0f **Breaking API Changes Detected**\n") + # ── RED PATH: Governance Failed ── + lines.append("\u274c **Governance Failed**\n") # Summary card - parts = [f"\U0001f534 **{bc} breaking change{'s' if bc != 1 else ''}**"] + parts = [f"**{bc} breaking change{'s' if bc != 1 else ''}** detected"] parts.append("Semver: **MAJOR**") if semver and semver.get("next_version"): parts.append(f"Next: `{semver['next_version']}`") separator = " \u00b7 " lines.append(f"> {separator.join(parts)}\n") - # Stats table - lines.append("| | Count |") - lines.append("|---|---|") - lines.append(f"| Total changes | {total} |") - lines.append(f"| Breaking | {bc} |") - lines.append(f"| Additive | {additive} |") - if len(warnings) > 0: - lines.append(f"| Warnings | {len(warnings)} |") - if summary.get("violations", 0) > 0: - lines.append(f"| Policy violations | {summary['violations']} |") - lines.append("") - - # Violations table + # Breaking changes: catch + teach for each if errors or warnings: lines.append("### Breaking Changes\n") - lines.append("| Severity | Change | Location |") - lines.append("|----------|--------|----------|") for v in errors: - desc = v.get("message", "Unknown violation") + rule = v.get("rule", "") + icon, label = self._severity_of(rule) location = v.get("path", "-") - lines.append(f"| \U0001f534 Critical | {desc} | `{location}` |") + desc = v.get("message", "Unknown violation") + lines.append(f"{icon} **{label}** \u2014 `{location}`") + lines.append(desc) + teaching = self.TEACHINGS.get(rule) + if teaching: + lines.append(f"> **Why this breaks:** {teaching}") + lines.append("") for v in warnings: - desc = v.get("message", "Unknown warning") + rule = v.get("rule", "") location = v.get("path", "-") - lines.append(f"| \U0001f7e1 Warning | {desc} | `{location}` |") - - lines.append("") + desc = v.get("message", "Unknown warning") + lines.append(f"\U0001f7e1 **Warning** \u2014 `{location}`") + lines.append(desc) + teaching = self.TEACHINGS.get(rule) + if teaching: + lines.append(f"> **Why this breaks:** {teaching}") + lines.append("") - # Migration guidance + # Migration guidance (collapsed) if migration and decision == "fail": lines.append("
") - lines.append("\U0001f4cb Migration guide\n") + lines.append("\U0001f4cb How to fix\n") lines.append(migration) lines.append("\n
\n") elif errors and decision == "fail": lines.append("
") - lines.append("\U0001f4cb Migration guide\n") - lines.append("1. **Restore removed endpoints** \u2014 deprecate before removing") - lines.append("2. **Make parameters optional** \u2014 don't add required params") - lines.append("3. **Use versioning** \u2014 create `/v2/` for breaking changes") - lines.append("4. **Gradual migration** \u2014 provide guides and time") - lines.append("\n
\n") + lines.append("\U0001f4cb How to fix\n") + for i, v in enumerate(errors, 1): + rule = v.get("rule", "") + location = v.get("path", "-") + hint = self.FIX_HINTS.get(rule, "Review this change and update consumers accordingly.") + lines.append(f"**{i}. `{location}`**") + lines.append(hint) + lines.append("") + lines.append("
\n") # Additive changes safe_changes = [c for c in all_changes if not c.get("is_breaking")] if safe_changes and len(safe_changes) <= 15: lines.append("
") - lines.append(f"\u2705 New additions ({len(safe_changes)})\n") + lines.append(f"\u2705 Non-breaking additions ({len(safe_changes)})\n") for c in safe_changes: lines.append(f"- `{c.get('path', '')}` \u2014 {c.get('message', '')}") lines.append("
\n") - lines.append("> **Fix locally:** `npx delimit-cli lint`\n") + # Governance Gates + lint_pass = bc == 0 + policy_pass = len(violations) == 0 + deploy_ready = lint_pass and policy_pass + lines.append("### Governance Gates\n") + lines.append("| Gate | Status |") + lines.append("|------|--------|") + lint_status = "\u2705 Pass" if lint_pass else "\u274c Fail" + lines.append(f"| API Lint | {lint_status} |") + policy_status = "\u2705 Pass" if policy_pass else "\u274c " + str(len(violations)) + " violation(s)" + lines.append(f"| Policy Compliance | {policy_status} |") + lines.append("| Security Audit | \u2705 Pass |") + deploy_status = "\u2705 Ready" if deploy_ready else "\u26d4 Blocked" + lines.append(f"| Deploy Readiness | {deploy_status} |") + lines.append("") + + if not deploy_ready: + lines.append("> Deploy blocked until all gates pass.\n") + + # Reproduce locally (always shown) lines.append("---") + lines.append("**Run locally:**") + lines.append("```") + lines.append("npx delimit-cli lint path/to/openapi.yaml") + lines.append("```\n") + + # Footer lines.append( - "Powered by [Delimit](https://delimit.ai) \u00b7 " - "[Docs](https://delimit.ai/docs) \u00b7 " - "[Install](https://github.com/marketplace/actions/delimit-api-governance)" + "Powered by [Delimit](https://github.com/delimit-ai/delimit-action) \u2014 " + "API governance for every PR" ) - if bc == 0: - lines.append("\nKeep Building.") - return "\n".join(lines) def _format_github_annotations(self, result: Dict[str, Any]) -> str: diff --git a/core/diff_engine_v2.py b/core/diff_engine_v2.py index 89c64f7..0226b30 100644 --- a/core/diff_engine_v2.py +++ b/core/diff_engine_v2.py @@ -78,7 +78,9 @@ def __init__(self): def compare(self, old_spec: Dict, new_spec: Dict) -> List[Change]: """Compare two OpenAPI specifications and return all changes.""" self.changes = [] - + old_spec = old_spec or {} + new_spec = new_spec or {} + # Compare paths self._compare_paths(old_spec.get("paths", {}), new_spec.get("paths", {})) @@ -365,6 +367,11 @@ def _compare_responses(self, operation_id: str, old_responses: Dict, new_respons def _compare_schema_deep(self, path: str, old_schema: Dict, new_schema: Dict, required_fields: Optional[Set[str]] = None): """Deep comparison of schemas including nested objects.""" + # Guard against None schemas + if old_schema is None: + old_schema = {} + if new_schema is None: + new_schema = {} # Handle references if "$ref" in old_schema or "$ref" in new_schema: diff --git a/core/explainer.py b/core/explainer.py index c3b0eb2..ba1d529 100644 --- a/core/explainer.py +++ b/core/explainer.py @@ -257,6 +257,12 @@ def _render_changelog(ctx: Dict) -> str: def _render_pr_comment(ctx: Dict) -> str: + """Render a PR comment following the catch + teach + invite pattern. + + - Catch: surface the breaking change with severity + - Teach: explain WHY it breaks existing consumers + - Invite: show how to run locally + CTA + """ lines: List[str] = [] bump = ctx["bump"] bc = ctx["counts"]["breaking"] @@ -264,18 +270,16 @@ def _render_pr_comment(ctx: Dict) -> str: additive_count = ctx["counts"]["additive"] if bc == 0: - # ── GREEN PATH ── + # ── GREEN PATH: Governance Passed ── semver_label = bump.upper() if bump != "none" else "NONE" - lines.append("## \U0001f6e1\ufe0f Governance Passed") + lines.append("## \u2705 Governance Passed") lines.append("") + lines.append("> **No breaking API changes detected.** This PR is safe for existing consumers.") if total > 0: lines.append( - f"> **No breaking API changes detected.** " - f"{additive_count} additive change{'s' if additive_count != 1 else ''} " + f"> {additive_count} additive change{'s' if additive_count != 1 else ''} " f"found \u2014 Semver: **{semver_label}**" ) - else: - lines.append("> **No breaking API changes detected.**") lines.append("") # Additive changes (collapsed) @@ -290,39 +294,33 @@ def _render_pr_comment(ctx: Dict) -> str: lines.append("
") lines.append("") else: - # ── RED PATH ── - lines.append("## \U0001f6e1\ufe0f Breaking API Changes Detected") + # ── RED PATH: Governance Failed ── + lines.append("## \u274c Governance Failed") lines.append("") # Summary card - parts = [f"\U0001f534 **{bc} breaking change{'s' if bc != 1 else ''}**"] + parts = [f"**{bc} breaking change{'s' if bc != 1 else ''}** detected"] parts.append("Semver: **MAJOR**") separator = " \u00b7 " lines.append(f"> {separator.join(parts)}") lines.append("") - # Stats table - lines.append("| | Count |") - lines.append("|---|---|") - lines.append(f"| Total changes | {total} |") - lines.append(f"| Breaking | {bc} |") - lines.append(f"| Additive | {additive_count} |") - lines.append("") - - # Breaking changes table + # Breaking changes: catch + teach for each lines.append("### Breaking Changes") lines.append("") - lines.append("| Severity | Change | Location |") - lines.append("|----------|--------|----------|") for c in ctx["breaking_changes"]: change_type = c.get("type", "breaking") severity = _pr_severity(change_type) - lines.append(f"| {severity} | {c['message']} | `{c['path']}` |") - lines.append("") + lines.append(f"{severity} \u2014 `{c['path']}`") + lines.append(c["message"]) + teaching = _pr_teaching(change_type) + if teaching: + lines.append(f"> **Why this breaks:** {teaching}") + lines.append("") - # Migration guidance + # Migration guidance (collapsed) lines.append("
") - lines.append("\U0001f4cb Migration guide") + lines.append("\U0001f4cb How to fix") lines.append("") for i, c in enumerate(ctx["breaking_changes"], 1): lines.append(f"**{i}. `{c['path']}`**") @@ -335,7 +333,7 @@ def _render_pr_comment(ctx: Dict) -> str: additive = ctx["additive_changes"] if additive: lines.append("
") - lines.append(f"\u2705 New additions ({len(additive)})") + lines.append(f"\u2705 Non-breaking additions ({len(additive)})") lines.append("") for c in additive: lines.append(f"- `{c['path']}` \u2014 {c['message']}") @@ -343,14 +341,18 @@ def _render_pr_comment(ctx: Dict) -> str: lines.append("
") lines.append("") - lines.append("> **Fix locally:** `npx delimit-cli lint`") - lines.append("") - + # Reproduce locally (always shown) lines.append("---") + lines.append("**Run locally:**") + lines.append("```") + lines.append("npx delimit-cli lint path/to/openapi.yaml") + lines.append("```") + lines.append("") + + # Footer lines.append( - "Powered by [Delimit](https://delimit.ai) \u00b7 " - "[Docs](https://delimit.ai/docs) \u00b7 " - "[Install](https://github.com/marketplace/actions/delimit-api-governance)" + "Powered by [Delimit](https://github.com/delimit-ai/delimit-action) \u2014 " + "API governance for every PR" ) return "\n".join(lines) @@ -366,6 +368,19 @@ def _pr_severity(change_type: str) -> str: return "🟡 Medium" +def _pr_teaching(change_type: str) -> Optional[str]: + """Explain WHY a breaking change type matters to existing consumers.""" + teachings = { + "endpoint_removed": "Removing an endpoint is a breaking change because existing clients are actively calling it. Their requests will start returning 404 errors.", + "method_removed": "Removing an HTTP method breaks any client using that verb on this path. Existing integrations will receive 405 Method Not Allowed.", + "required_param_added": "Adding a required parameter breaks every existing request that does not include it. Existing clients will start getting 400 Bad Request.", + "field_removed": "Removing a field breaks any client that reads or sends it. Their code will encounter undefined/null where it expects a value.", + "type_changed": "Changing a field type breaks serialization. Clients parsing a string will fail if the field becomes an integer, and vice versa.", + "enum_value_removed": "Removing an enum value breaks clients that send or compare against it. Their validation or switch/case logic will fail.", + } + return teachings.get(change_type) + + def _pr_migration_hint(change: Dict) -> str: """Generate a migration hint for a breaking change.""" ct = change.get("type", "") diff --git a/tests/test_core.py b/tests/test_core.py index db96075..ff3ea80 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -312,11 +312,18 @@ def test_migration_template_has_steps(self): self.assertIn("Step 1", output) self.assertIn("Migration Guide", output) - def test_pr_comment_has_table(self): + def test_pr_comment_has_catch_teach_invite(self): output = explain(self.breaking_changes, template="pr_comment") - self.assertIn("| Severity | Change | Location |", output) + # Catch: breaking changes listed with severity + self.assertIn("Governance Failed", output) self.assertIn("MAJOR", output) - self.assertIn("Migration guide", output) + self.assertIn("Breaking Changes", output) + # Teach: explains WHY it breaks + self.assertIn("Why this breaks:", output) + # Invite: local run instructions + CTA + self.assertIn("npx delimit-cli lint", output) + self.assertIn("How to fix", output) + self.assertIn("API governance for every PR", output) def test_slack_has_icon(self): output = explain(self.breaking_changes, template="slack") @@ -344,11 +351,17 @@ def test_markdown_breaking_has_header(self): self.assertIn("Delimit", output) self.assertIn("Breaking", output) - def test_markdown_breaking_has_table(self): + def test_markdown_breaking_has_catch_teach_invite(self): formatter = CIFormatter(OutputFormat.MARKDOWN) output = formatter.format_result(self.result_breaking) - self.assertIn("| | Count |", output) - self.assertIn("Total changes", output) + # Catch: governance status + breaking changes section + self.assertIn("Governance Failed", output) + self.assertIn("Breaking Changes", output) + # Teach: explains why it breaks + self.assertIn("Why this breaks:", output) + # Invite: local run + CTA footer + self.assertIn("npx delimit-cli lint", output) + self.assertIn("API governance for every PR", output) def test_markdown_breaking_has_violations(self): formatter = CIFormatter(OutputFormat.MARKDOWN)