diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml new file mode 100644 index 000000000..6af4aa09b --- /dev/null +++ b/.github/workflows/openapi.yml @@ -0,0 +1,85 @@ +name: OpenAPI + +on: + pull_request: + paths: + - "lib/realtime_web/**" + - "lib/mix/tasks/openapi.export.ex" + - "priv/openapi.json" + - "mix.exs" + - "mix.lock" + - ".github/workflows/openapi.yml" + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: openapi-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + drift_check: + name: Spec drift check + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + env: + MIX_ENV: dev + + steps: + - uses: actions/checkout@v4 + + - name: Verify committed OpenAPI snapshot exists + run: | + if ! git ls-files --error-unmatch priv/openapi.json > /dev/null 2>&1; then + echo "::error file=priv/openapi.json::OpenAPI snapshot is not yet committed to the repository." + echo "" + echo "Bootstrap one-time: run 'mix openapi.export' locally and commit" + echo "priv/openapi.json. Or download the 'openapi-spec' artifact from a" + echo "previous run on this PR and commit it." + exit 1 + fi + + - uses: erlef/setup-beam@v1 + with: + version-file: .tool-versions + version-type: strict + + - uses: actions/cache@v4 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix- + + - name: Install dependencies + run: | + mix local.hex --force + mix local.rebar --force + mix deps.get + + - name: Regenerate OpenAPI spec + run: mix openapi.export + + - name: Fail if committed spec is out of date + run: | + if ! git diff --exit-code -- priv/openapi.json; then + echo "" + echo "::error::The OpenAPI spec is out of date." + echo "Run 'mix openapi.export' locally and commit priv/openapi.json." + exit 1 + fi + + - name: Upload spec artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: openapi-spec + path: priv/openapi.json + if-no-files-found: warn diff --git a/lib/mix/tasks/openapi.export.ex b/lib/mix/tasks/openapi.export.ex new file mode 100644 index 000000000..ddb2cd50a --- /dev/null +++ b/lib/mix/tasks/openapi.export.ex @@ -0,0 +1,34 @@ +defmodule Mix.Tasks.Openapi.Export do + @moduledoc """ + Exports the RealtimeWeb OpenAPI spec to a JSON file on disk. + + mix openapi.export # writes priv/openapi.json + mix openapi.export --output path.json # writes the given path + + Used by CI to enforce that the committed spec stays in sync with the routes. + """ + use Mix.Task + + @shortdoc "Exports the RealtimeWeb OpenAPI spec to a JSON file" + + @default_output "priv/openapi.json" + + @impl Mix.Task + def run(args) do + {opts, _, _} = + OptionParser.parse(args, strict: [output: :string], aliases: [o: :output]) + + output = Keyword.get(opts, :output, @default_output) + + Mix.Task.run("app.start") + + spec = + RealtimeWeb.ApiSpec.spec() + |> Jason.encode!(pretty: true) + + output |> Path.dirname() |> File.mkdir_p!() + File.write!(output, spec <> "\n") + + Mix.shell().info("Wrote OpenAPI spec to #{output}") + end +end