diff --git a/.github/workflows/test-validate.yaml b/.github/workflows/test-validate.yaml new file mode 100644 index 0000000..4d8ce4c --- /dev/null +++ b/.github/workflows/test-validate.yaml @@ -0,0 +1,81 @@ +name: validate +on: + pull_request: + push: +jobs: + oasdiff_validate_valid: + runs-on: ubuntu-latest + name: Test validate on a valid spec + steps: + - name: checkout + uses: actions/checkout@v6 + - name: Running validate action on a valid spec + id: test_validate_valid + uses: ./validate + with: + spec: 'specs/valid.yaml' + - name: Test validate reports zero findings + run: | + findings="${{ steps.test_validate_valid.outputs.findings }}" + if [ "$findings" != "0" ]; then + echo "Expected 0 findings, got '$findings'" >&2 + exit 1 + fi + oasdiff_validate_findings: + runs-on: ubuntu-latest + name: Test validate fails on an invalid spec + steps: + - name: checkout + uses: actions/checkout@v6 + - name: Running validate action on an invalid spec + id: test_validate_findings + continue-on-error: true + uses: ./validate + with: + spec: 'specs/invalid.yaml' + - name: Test validate failed and reported findings + run: | + if [ "${{ steps.test_validate_findings.outcome }}" != "failure" ]; then + echo "Expected the validate step to fail on error-level findings" >&2 + exit 1 + fi + findings="${{ steps.test_validate_findings.outputs.findings }}" + if [ "$findings" = "0" ] || [ -z "$findings" ]; then + echo "Expected findings > 0, got '$findings'" >&2 + exit 1 + fi + oasdiff_validate_fail_on: + runs-on: ubuntu-latest + name: Test validate severity threshold (--fail-on) + steps: + - name: checkout + uses: actions/checkout@v6 + - name: Validate a warning-only spec with the default threshold + id: test_validate_warn_default + uses: ./validate + with: + spec: 'specs/validate-warning.yaml' + - name: Default threshold reports the warning but passes + run: | + if [ "${{ steps.test_validate_warn_default.outcome }}" != "success" ]; then + echo "Expected the step to pass (warnings don't fail by default)" >&2 + exit 1 + fi + warnings="${{ steps.test_validate_warn_default.outputs.warning_count }}" + if [ "$warnings" != "1" ]; then + echo "Expected warning_count 1, got '$warnings'" >&2 + exit 1 + fi + - name: Validate the same spec with fail-on WARN + id: test_validate_warn_failon + continue-on-error: true + uses: ./validate + with: + spec: 'specs/validate-warning.yaml' + fail-on: 'WARN' + - name: fail-on WARN escalates the warning to a failure + run: | + if [ "${{ steps.test_validate_warn_failon.outcome }}" != "failure" ]; then + echo "Expected the step to fail with fail-on WARN" >&2 + exit 1 + fi diff --git a/README.md b/README.md index 022e497..4fe905f 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![changelog](https://github.com/oasdiff/oasdiff-action/actions/workflows/test-changelog.yaml/badge.svg)](https://github.com/oasdiff/oasdiff-action/actions/workflows/test-changelog.yaml) [![diff](https://github.com/oasdiff/oasdiff-action/actions/workflows/test-diff.yaml/badge.svg)](https://github.com/oasdiff/oasdiff-action/actions/workflows/test-diff.yaml) [![pr-comment](https://github.com/oasdiff/oasdiff-action/actions/workflows/test-pr-comment.yaml/badge.svg)](https://github.com/oasdiff/oasdiff-action/actions/workflows/test-pr-comment.yaml) +[![validate](https://github.com/oasdiff/oasdiff-action/actions/workflows/test-validate.yaml/badge.svg)](https://github.com/oasdiff/oasdiff-action/actions/workflows/test-validate.yaml) GitHub Actions for comparing OpenAPI specs and detecting breaking changes, based on [oasdiff](https://github.com/oasdiff/oasdiff). @@ -13,6 +14,7 @@ GitHub Actions for comparing OpenAPI specs and detecting breaking changes, based - [Check for breaking changes](#check-for-breaking-changes) - [Generate a changelog](#generate-a-changelog) - [Generate a diff report](#generate-a-diff-report) + - [Validate a single spec](#validate-a-single-spec) - [Configuring with `.oasdiff.yaml`](#configuring-with-oasdiffyaml) - [Spec paths](#spec-paths) - [Pro: Rich PR comment](#pro-rich-pr-comment) @@ -159,6 +161,33 @@ jobs: | `flatten-allof` | `false` | Merge allOf subschemas into a single schema before diff | `true`, `false` | | `output-to-file` | `''` | Write output to this file path instead of stdout | file path | +### Validate a single spec + +Validates one OpenAPI spec against the OpenAPI and JSON Schema rules and writes an inline GitHub annotation for each finding. Unlike the other actions it takes a single spec, not a base/revision pair. Findings are classified by severity (error, warning, info); by default the workflow fails only on errors. + +```yaml +name: oasdiff +on: + pull_request: + branches: [ "main" ] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: oasdiff/oasdiff-action/validate@v0.0.48 + with: + spec: 'openapi.yaml' +``` + +| Input | Default | Description | Accepted values | +|---|---|---|---| +| `spec` | — (required) | Path to the OpenAPI spec to validate | file path, URL, git ref | +| `fail-on` | `''` | Fail with exit code 1 when a finding is at or above this severity (empty uses the oasdiff default, `ERR`) | `ERR`, `WARN`, `INFO` | +| `allow-external-refs` | `true` | Resolve external `$ref`s; set `false` to prevent SSRF when validating untrusted specs | `true`, `false` | + +For a non-blocking, report-only run, leave `fail-on` and set `continue-on-error: true` on the step. Outputs: `findings` (total), `error_count`, `warning_count`, `info_count`. + --- ## Configuring with `.oasdiff.yaml` diff --git a/release.sh b/release.sh index ccfc9b2..a98b7de 100755 --- a/release.sh +++ b/release.sh @@ -11,7 +11,7 @@ set -e REPO_DIR="$(cd "$(dirname "$0")" && pwd)" -DOCKERFILES="breaking/Dockerfile changelog/Dockerfile diff/Dockerfile pr-comment/Dockerfile" +DOCKERFILES="breaking/Dockerfile changelog/Dockerfile diff/Dockerfile validate/Dockerfile pr-comment/Dockerfile" # ── Resolve action version ─────────────────────────────────────────────────── diff --git a/specs/invalid.yaml b/specs/invalid.yaml new file mode 100644 index 0000000..f0e851e --- /dev/null +++ b/specs/invalid.yaml @@ -0,0 +1,4 @@ +openapi: 3.0.0 +info: + title: invalid +paths: {} diff --git a/specs/valid.yaml b/specs/valid.yaml new file mode 100644 index 0000000..b034c88 --- /dev/null +++ b/specs/valid.yaml @@ -0,0 +1,10 @@ +openapi: 3.0.0 +info: + title: valid + version: "1.0.0" +paths: + /things: + get: + responses: + '200': + description: ok diff --git a/specs/validate-warning.yaml b/specs/validate-warning.yaml new file mode 100644 index 0000000..a816f09 --- /dev/null +++ b/specs/validate-warning.yaml @@ -0,0 +1,8 @@ +openapi: 3.0.0 +info: + title: warning + version: "1.0.0" + license: + name: MIT + identifier: MIT +paths: {} diff --git a/validate/Dockerfile b/validate/Dockerfile new file mode 100644 index 0000000..d77b893 --- /dev/null +++ b/validate/Dockerfile @@ -0,0 +1,4 @@ +FROM tufin/oasdiff:v1.16.0 +ENV PLATFORM github-action +COPY entrypoint.sh /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/validate/action.yml b/validate/action.yml new file mode 100644 index 0000000..b358264 --- /dev/null +++ b/validate/action.yml @@ -0,0 +1,30 @@ +name: 'Validate an OpenAPI spec' +description: 'Validate an OpenAPI spec against the spec, with per-finding PR annotations' +inputs: + spec: + description: 'Path of the OpenAPI spec in YAML or JSON format' + required: true + fail-on: + description: 'Fail with exit code 1 when a finding has this severity or higher: ERR, WARN, or INFO. Defaults to ERR (errors fail the build; warnings and info are reported but do not). For a non-blocking, report-only run, set continue-on-error on the step.' + required: false + default: '' + allow-external-refs: + description: 'Allow external $refs in the spec; disable to prevent SSRF when validating untrusted specs' + required: false + default: 'true' +outputs: + findings: + description: 'Total number of findings reported by validate (0 if the spec is valid)' + error_count: + description: 'Number of error-level findings' + warning_count: + description: 'Number of warning-level findings' + info_count: + description: 'Number of info-level findings' +runs: + using: 'docker' + image: 'Dockerfile' + args: + - ${{ inputs.spec }} + - ${{ inputs.fail-on }} + - ${{ inputs.allow-external-refs }} diff --git a/validate/entrypoint.sh b/validate/entrypoint.sh new file mode 100755 index 0000000..4899134 --- /dev/null +++ b/validate/entrypoint.sh @@ -0,0 +1,65 @@ +#!/bin/sh +set -e + +if [ -n "$GITHUB_WORKSPACE" ]; then + git config --global --get-all safe.directory | grep -q "$GITHUB_WORKSPACE" || \ + git config --global --add safe.directory "$GITHUB_WORKSPACE" +fi + +readonly spec="$1" +readonly fail_on="$2" +readonly allow_external_refs="$3" + +echo "running oasdiff validate... spec: $spec, fail_on: $fail_on, allow_external_refs: $allow_external_refs" + +# Build flags. --allow-external-refs defaults to true in oasdiff, so only +# pass it when the input opts out. --fail-on defaults to ERR in oasdiff +# (errors fail the build; warnings and info are reported but don't), so only +# pass it when the input overrides the threshold. +flags="" +if [ "$allow_external_refs" = "false" ]; then + flags="$flags --allow-external-refs=false" +fi +if [ -n "$fail_on" ]; then + flags="$flags --fail-on $fail_on" +fi +echo "flags: $flags" + +# Run 1: render annotations to stdout via --format githubactions so GitHub +# parses them onto the PR's "Files changed" tab. This is the authoritative +# run: its exit code honours --fail-on (1 when a finding is at or above the +# threshold, 0 otherwise). Tolerate non-zero so we can still set the outputs +# below; the exit code is reapplied at the end. +exit_code=0 +oasdiff validate $flags --format githubactions "$spec" || exit_code=$? + +# Run 2: text format, captured for the finding count. Tolerate non-zero +# exit (the authoritative decision is already captured above). +findings_text=$(oasdiff validate $flags "$spec") || true + +# *** GitHub Action step output *** + +# Total finding count from the header "N findings: N error, N warning, N info". +# A valid spec prints nothing, so the count stays 0. +findings_count=0 +if [ -n "$findings_text" ]; then + header=$(printf '%s' "$findings_text" | head -n 1) + n=$(printf '%s' "$header" | awk '{print $1}') + if printf '%s' "$n" | grep -qE '^[0-9]+$'; then + findings_count="$n" + fi +fi +echo "findings=$findings_count" >>"$GITHUB_OUTPUT" + +# The --format githubactions run above writes error_count/warning_count/ +# info_count to GITHUB_OUTPUT, but only when there are findings. Emit zeros +# for a valid spec so those outputs are always present for callers. +if [ "$findings_count" -eq 0 ]; then + { + echo "error_count=0" + echo "warning_count=0" + echo "info_count=0" + } >>"$GITHUB_OUTPUT" +fi + +exit "$exit_code"