diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1eab66..8c11df1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,6 +104,20 @@ jobs: dist/*.tar.gz dist/*.tar.gz.sha256 + update-action-tag: + needs: [version, release] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + - name: Update major version tag + run: | + version="${{ needs.version.outputs.value }}" + major="v$(echo "$version" | cut -d. -f1)" + git tag -f "$major" + git push origin "$major" --force + update-formula: needs: [version, release] runs-on: ubuntu-latest diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml new file mode 100644 index 0000000..e629e31 --- /dev/null +++ b/.github/workflows/test-action.yml @@ -0,0 +1,76 @@ +name: test-action + +on: + push: + branches: [main] + paths: + - "action/**" + - "tests/fixtures/**" + - ".github/workflows/test-action.yml" + pull_request: + branches: [main] + paths: + - "action/**" + - "tests/fixtures/**" + - ".github/workflows/test-action.yml" + +jobs: + test-setup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Run setup action + uses: ./action/setup + + - name: Verify oav is on PATH + run: oav --version + + test-validate-pass: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup oav + uses: ./action/setup + + - name: Run validate (should pass) + id: validate + uses: ./action/validate + with: + spec: tests/fixtures/valid.yml + skip-generate: "true" + skip-compile: "true" + upload-reports: "false" + + - name: Assert pass + run: | + if [[ "${{ steps.validate.outputs.result }}" != "pass" ]]; then + echo "::error::Expected result=pass, got ${{ steps.validate.outputs.result }}" + exit 1 + fi + + test-validate-failure: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup oav + uses: ./action/setup + + - name: Run validate (should fail) + id: validate + uses: ./action/validate + continue-on-error: true + with: + spec: tests/fixtures/invalid.yml + skip-generate: "true" + skip-compile: "true" + upload-reports: "false" + + - name: Assert failure + run: | + if [[ "${{ steps.validate.outputs.result }}" != "fail" ]]; then + echo "::error::Expected result=fail, got ${{ steps.validate.outputs.result }}" + exit 1 + fi diff --git a/README.md b/README.md index 4aab3b1..e521641 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,85 @@ cargo install --git https://github.com/entur/openapi-validator-cli - Cargo: `cargo uninstall oav` - Curl/manual: `rm /usr/local/bin/oav` (or wherever you installed it) +## GitHub Action + +Use oav directly in GitHub Actions workflows. The validate action requires Docker, so it must run on `ubuntu-latest` (or other Linux runners). + +### Basic usage + +```yaml +- uses: entur/openapi-validator-cli/action/setup@v0 + +- uses: entur/openapi-validator-cli/action/validate@v0 + with: + spec: openapi/api.yaml +``` + +### Full example + +```yaml +name: Validate OpenAPI +on: [push, pull_request] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup oav + uses: entur/openapi-validator-cli/action/setup@v0 + + - name: Validate OpenAPI spec + id: oav + uses: entur/openapi-validator-cli/action/validate@v0 + with: + spec: openapi/api.yaml + mode: both + skip-compile: "true" +``` + +### Setup only + +If you want to use oav commands directly: + +```yaml +- uses: entur/openapi-validator-cli/action/setup@v0 + with: + version: "0.3.0" + +- run: | + oav init --spec openapi/api.yaml + oav validate --color never +``` + +### Inputs (validate action) + +| Input | Default | Description | +|---------------------|-----------|------------------------------------------| +| `spec` | — | Path to OpenAPI spec file | +| `mode` | — | Validation mode (server, client, both) | +| `server-generators` | — | Comma-separated server generators | +| `client-generators` | — | Comma-separated client generators | +| `skip-lint` | `false` | Skip the lint step | +| `skip-generate` | `false` | Skip the generate step | +| `skip-compile` | `false` | Skip the compile step | +| `linter` | — | Linter to use (spectral, redocly, none) | +| `ruleset` | — | Path to custom Spectral ruleset | +| `docker-timeout` | — | Docker timeout in seconds | +| `search-depth` | — | Max directory depth for spec search | +| `working-directory` | `.` | Working directory for oav commands | +| `upload-reports` | `true` | Upload reports as workflow artifact | + +### Outputs + +| Output | Description | +|----------|------------------------------------------| +| `result` | `pass`, `fail`, or `error` | +| `total` | Total number of validation targets | +| `passed` | Number of passed targets | +| `failed` | Number of failed targets | + ## Commands - `oav init` — create `.oav/`, scaffold `.oavc`, and add gitignore entries diff --git a/action/setup/action.yml b/action/setup/action.yml new file mode 100644 index 0000000..be73018 --- /dev/null +++ b/action/setup/action.yml @@ -0,0 +1,70 @@ +name: Setup oav +description: Download and install the oav OpenAPI validator CLI + +inputs: + version: + description: Version to install (e.g. 0.3.0). Use "latest" for the most recent release. + required: false + default: latest + token: + description: GitHub token for API access and artifact downloads + required: false + default: ${{ github.token }} + +outputs: + version: + description: Resolved version that was installed + value: ${{ steps.install.outputs.version }} + +runs: + using: composite + steps: + - name: Resolve version and target + id: resolve + shell: bash + run: | + version="${{ inputs.version }}" + if [[ "$version" == "latest" ]]; then + json="$(curl -fsSL -H "Authorization: token ${{ inputs.token }}" \ + "https://api.github.com/repos/entur/openapi-validator-cli/releases/latest")" + tag="$(printf '%s' "$json" | awk -F\" '/"tag_name":/ {print $4; exit}')" + if [[ -z "$tag" ]]; then + echo "::error::Unable to determine latest release tag" + exit 1 + fi + version="${tag#v}" + fi + + os="$(uname -s)" + arch="$(uname -m)" + case "$os" in + Darwin) platform="apple-darwin" ;; + Linux) platform="unknown-linux-gnu" ;; + *) echo "::error::Unsupported OS: $os"; exit 1 ;; + esac + case "$arch" in + x86_64) arch="x86_64" ;; + arm64|aarch64) arch="aarch64" ;; + *) echo "::error::Unsupported architecture: $arch"; exit 1 ;; + esac + + target="${arch}-${platform}" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "target=${target}" >> "$GITHUB_OUTPUT" + + - name: Cache oav binary + id: cache + uses: actions/cache@v4 + with: + path: ${{ runner.tool_cache }}/oav/${{ steps.resolve.outputs.version }} + key: oav-${{ steps.resolve.outputs.version }}-${{ steps.resolve.outputs.target }} + + - name: Install oav + id: install + shell: bash + run: bash "${{ github.action_path }}/install.sh" + env: + INPUT_VERSION: ${{ steps.resolve.outputs.version }} + INPUT_TARGET: ${{ steps.resolve.outputs.target }} + INPUT_TOKEN: ${{ inputs.token }} + CACHE_HIT: ${{ steps.cache.outputs.cache-hit }} diff --git a/action/setup/install.sh b/action/setup/install.sh new file mode 100755 index 0000000..6ca1ab2 --- /dev/null +++ b/action/setup/install.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +version="${INPUT_VERSION}" +target="${INPUT_TARGET}" +token="${INPUT_TOKEN}" +install_dir="${RUNNER_TOOL_CACHE:-$HOME/.oav/bin}/oav/${version}" + +mkdir -p "$install_dir" + +if [[ "${CACHE_HIT}" != "true" ]]; then + base_url="https://github.com/entur/openapi-validator-cli/releases/download/v${version}" + asset="oav-${version}-${target}.tar.gz" + sha_asset="${asset}.sha256" + + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + + echo "Downloading oav v${version} for ${target}..." + curl -fsSL -H "Authorization: token ${token}" \ + "${base_url}/${asset}" -o "${tmpdir}/${asset}" + curl -fsSL -H "Authorization: token ${token}" \ + "${base_url}/${sha_asset}" -o "${tmpdir}/${sha_asset}" + + echo "Verifying checksum..." + (cd "$tmpdir" && sha256sum -c "$sha_asset") + + tar -xzf "${tmpdir}/${asset}" -C "$tmpdir" + install -m 0755 "${tmpdir}/oav" "${install_dir}/oav" + + echo "Installed oav v${version} to ${install_dir}" +else + echo "Using cached oav v${version}" +fi + +echo "version=${version}" >> "$GITHUB_OUTPUT" +echo "${install_dir}" >> "$GITHUB_PATH" diff --git a/action/validate/action.yml b/action/validate/action.yml new file mode 100644 index 0000000..5c25444 --- /dev/null +++ b/action/validate/action.yml @@ -0,0 +1,102 @@ +name: Validate OpenAPI specs +description: Run oav to lint, generate, and compile OpenAPI specs + +inputs: + spec: + description: Path to OpenAPI spec file + required: false + mode: + description: Validation mode (server, client, both) + required: false + server-generators: + description: Comma-separated list of server generators + required: false + client-generators: + description: Comma-separated list of client generators + required: false + skip-lint: + description: Skip the lint step + required: false + default: "false" + skip-generate: + description: Skip the generate step + required: false + default: "false" + skip-compile: + description: Skip the compile step + required: false + default: "false" + linter: + description: Linter to use (spectral, redocly, none) + required: false + ruleset: + description: Path to custom Spectral ruleset + required: false + docker-timeout: + description: Docker operation timeout in seconds + required: false + search-depth: + description: Max directory depth when searching for spec files + required: false + working-directory: + description: Working directory for oav commands + required: false + default: "." + upload-reports: + description: Upload .oav/reports/ as a workflow artifact + required: false + default: "true" + +outputs: + result: + description: "Validation result: pass, fail, or error" + value: ${{ steps.validate.outputs.result }} + total: + description: Total number of validation targets + value: ${{ steps.validate.outputs.total }} + passed: + description: Number of passed targets + value: ${{ steps.validate.outputs.passed }} + failed: + description: Number of failed targets + value: ${{ steps.validate.outputs.failed }} + +runs: + using: composite + steps: + - name: Run oav validate + id: validate + shell: bash + working-directory: ${{ inputs.working-directory }} + run: bash "${{ github.action_path }}/validate.sh" + env: + INPUT_SPEC: ${{ inputs.spec }} + INPUT_MODE: ${{ inputs.mode }} + INPUT_SERVER_GENERATORS: ${{ inputs.server-generators }} + INPUT_CLIENT_GENERATORS: ${{ inputs.client-generators }} + INPUT_SKIP_LINT: ${{ inputs.skip-lint }} + INPUT_SKIP_GENERATE: ${{ inputs.skip-generate }} + INPUT_SKIP_COMPILE: ${{ inputs.skip-compile }} + INPUT_LINTER: ${{ inputs.linter }} + INPUT_RULESET: ${{ inputs.ruleset }} + INPUT_DOCKER_TIMEOUT: ${{ inputs.docker-timeout }} + INPUT_SEARCH_DEPTH: ${{ inputs.search-depth }} + + - name: Generate step summary + if: always() + shell: bash + working-directory: ${{ inputs.working-directory }} + run: bash "${{ github.action_path }}/summary.sh" + + - name: Upload reports + if: always() && inputs.upload-reports == 'true' + uses: actions/upload-artifact@v4 + with: + name: oav-reports + path: ${{ inputs.working-directory }}/.oav/reports/ + if-no-files-found: ignore + + - name: Set exit code + if: always() + shell: bash + run: exit ${{ steps.validate.outputs.exit_code }} diff --git a/action/validate/summary.sh b/action/validate/summary.sh new file mode 100755 index 0000000..98c60cd --- /dev/null +++ b/action/validate/summary.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +status_file=".oav/status.tsv" + +if [[ ! -f "$status_file" ]]; then + echo "No status.tsv found — skipping step summary." + exit 0 +fi + +total=0 +passed=0 +failed=0 + +rows="" +while IFS=$'\t' read -r stage scope target status _; do + total=$((total + 1)) + case "$status" in + ok) passed=$((passed + 1)); icon="+" ;; + fail) failed=$((failed + 1)); icon="x" ;; + *) icon="-" ;; + esac + rows="${rows}| ${stage} | ${scope} | ${target} | ${icon} ${status} |"$'\n' +done < "$status_file" + +if [[ $failed -gt 0 ]]; then + header="OpenAPI Validation: ${passed}/${total} passed, ${failed} failed" +else + header="OpenAPI Validation: ${total}/${total} passed" +fi + +{ + echo "## ${header}" + echo "" + echo "| Stage | Scope | Target | Status |" + echo "|-------|-------|--------|--------|" + echo -n "${rows}" +} >> "$GITHUB_STEP_SUMMARY" diff --git a/action/validate/validate.sh b/action/validate/validate.sh new file mode 100755 index 0000000..d2ff5c9 --- /dev/null +++ b/action/validate/validate.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -uo pipefail + +# Build init args +init_args=() +if [[ -n "${INPUT_SPEC}" ]]; then + init_args+=(--spec "${INPUT_SPEC}") +fi + +# Run init (non-fatal — validate will also scaffold if needed) +oav init "${init_args[@]}" 2>&1 || true + +# Build validate args +args=(--color never) + +if [[ -n "${INPUT_SPEC}" ]]; then + args+=(--spec "${INPUT_SPEC}") +fi +if [[ -n "${INPUT_MODE}" ]]; then + args+=(--mode "${INPUT_MODE}") +fi +if [[ -n "${INPUT_SERVER_GENERATORS}" ]]; then + args+=(--server-generators "${INPUT_SERVER_GENERATORS}") +fi +if [[ -n "${INPUT_CLIENT_GENERATORS}" ]]; then + args+=(--client-generators "${INPUT_CLIENT_GENERATORS}") +fi +if [[ "${INPUT_SKIP_LINT}" == "true" ]]; then + args+=(--skip-lint) +fi +if [[ "${INPUT_SKIP_GENERATE}" == "true" ]]; then + args+=(--skip-generate) +fi +if [[ "${INPUT_SKIP_COMPILE}" == "true" ]]; then + args+=(--skip-compile) +fi +if [[ -n "${INPUT_LINTER}" ]]; then + args+=(--linter "${INPUT_LINTER}") +fi +if [[ -n "${INPUT_RULESET}" ]]; then + args+=(--ruleset "${INPUT_RULESET}") +fi +if [[ -n "${INPUT_DOCKER_TIMEOUT}" ]]; then + args+=(--docker-timeout "${INPUT_DOCKER_TIMEOUT}") +fi +if [[ -n "${INPUT_SEARCH_DEPTH}" ]]; then + args+=(--search-depth "${INPUT_SEARCH_DEPTH}") +fi + +echo "Running: oav validate ${args[*]}" + +set +e +oav validate "${args[@]}" 2>&1 +exit_code=$? +set -e + +# Map exit code to result +case $exit_code in + 0) result="pass" ;; + 1) result="fail" ;; + *) result="error" ;; +esac + +# Parse status.tsv for counts +total=0 +passed=0 +failed=0 +if [[ -f .oav/status.tsv ]]; then + while IFS=$'\t' read -r _stage _scope _target status _log; do + total=$((total + 1)) + case "$status" in + ok) passed=$((passed + 1)) ;; + fail) failed=$((failed + 1)) ;; + esac + done < .oav/status.tsv +fi + +{ + echo "result=${result}" + echo "exit_code=${exit_code}" + echo "total=${total}" + echo "passed=${passed}" + echo "failed=${failed}" +} >> "$GITHUB_OUTPUT" diff --git a/tests/fixtures/valid.yml b/tests/fixtures/valid.yml index 3c280f6..5a4b232 100644 --- a/tests/fixtures/valid.yml +++ b/tests/fixtures/valid.yml @@ -168,12 +168,17 @@ components: description: Whether the time is real-time. Error: type: object - description: Error response. + description: Error response following RFC 9457. required: - - code - - message + - status + - title properties: - code: + status: + type: integer + description: HTTP status code. + title: type: string - message: - type: string \ No newline at end of file + description: Short summary of the problem type. + detail: + type: string + description: Human-readable explanation. \ No newline at end of file