arcature 0.1.0

Arcature: an opinionated full-stack Rust web framework. One package, batteries included.
Documentation
name: CI

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    # The feature powerset, nightly. See the `powerset` job for why it is not
    # on the pull-request path.
    - cron: "17 4 * * *"

# Every job here checks out the repository, compiles it, and reports through
# the check run the platform creates on its own. None of them writes an issue,
# a comment, a release, or a package, so the token they are handed should not
# be able to. Declared once at the top rather than nine times: a job added
# later inherits the restriction instead of inheriting the default, which is
# the failure mode this block exists to prevent.
permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  RUSTFLAGS: "-D warnings"

  # `rust-toolchain.toml` pins `stable`, and a toolchain file beats the
  # toolchain `dtolnay/rust-toolchain` installs. Without an override every leg
  # of the matrix below would build with stable and the MSRV leg would prove
  # nothing. `RUSTUP_TOOLCHAIN` outranks the file, so each job sets it.
  RUSTUP_TOOLCHAIN: stable

jobs:
  test:
    name: Test (${{ matrix.rust }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        rust: ["1.97.1", "stable"]

    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: arcature_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    env:
      DATABASE_URL: postgres://postgres:postgres@localhost:5432/arcature_test
      RUSTUP_TOOLCHAIN: ${{ matrix.rust }}

    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1
        with:
          toolchain: ${{ matrix.rust }}
          components: clippy, rustfmt

      - name: Check formatting
        run: cargo fmt --all -- --check

      - name: Clippy (default features)
        run: cargo clippy --all-targets

      - name: Build (default features)
        run: cargo build

      - name: Test (default features)
        run: cargo test

      - name: Build (no default features, kernel only)
        run: cargo build --no-default-features

      - name: Build (fullstack)
        run: cargo build --features fullstack

      # Everything that does not touch a database. `jobs` and `cli` are absent
      # on purpose and are not oversights: `jobs` pulls `database` directly and
      # `cli` pulls it through `uag -> dx -> jobs`, so naming either here makes
      # this a database build with no driver, which is the one thing
      # `src/database/mod.rs` refuses to compile. `just drivers` covers them.
      - name: Build (no database)
        run: cargo build --no-default-features --features "macros,inertia,auth,validation,cache,storage-fs,mail,events,api,observe,pages,realtime,templates"

  drivers:
    name: Full features, per driver
    runs-on: ubuntu-latest

    # The honest equivalent of `--all-features`, which cannot work here: a
    # build speaks exactly one SQL dialect, so "all features" is three builds
    # and not one. Anything that hard-codes a driver type, or writes SQL only
    # PostgreSQL parses, fails here and nowhere else -- the `features` job pins
    # PostgreSQL and would never see it. Mirrors `just drivers`.
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1
        with:
          toolchain: stable

      - name: Build the full feature set once per driver
        run: |
          feats=api,api-docs,auth,cache,cli,database,dev-proxy,dx,events,inertia,jobs,macros,mail
          feats=$feats,oauth,observe,otel,pages,realtime,storage-fs,storage-s3,templates,test-kit,uag,validation
          for driver in db-postgres db-sqlite db-mysql; do
            echo "== $driver =="
            cargo check --no-default-features --features "$feats,$driver" --all-targets
          done

  features:
    name: Each feature alone
    runs-on: ubuntu-latest

    # The flags are not tuning. They are the compile-time invariant in
    # `src/database/mod.rs` written out as cargo-hack arguments:
    #
    #   --features db-postgres     six features (`database`, `jobs`, `dx`,
    #                              `uag`, `cli`, `api-docs`) pull `database`
    #                              without naming a driver, so on their own
    #                              they hit the "needs a driver" error. The
    #                              driver is a build-wide choice like a target,
    #                              not something each feature opts into.
    #   --skip db-sqlite,db-mysql  otherwise cargo-hack adds a second driver on
    #                              top of the pinned one. The `drivers` job
    #                              gives the other two a build of their own.
    #   --exclude-all-features     `--all-features` is all three drivers at
    #                              once, which never compiles.
    #
    # `--no-dev-deps` keeps this to the library: a test file that forgets its
    # own `#![cfg(feature = ...)]` is a test-file bug, not a library one.
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1
        with:
          toolchain: stable

      - uses: taiki-e/install-action@a2a5f6e99e1a31540baa0468acfa302cff0f359f # v2.86.4
        with:
          tool: cargo-hack

      - name: Every feature on its own
        run: cargo hack check --each-feature --no-dev-deps --features db-postgres --skip db-sqlite,db-mysql --exclude-all-features

  powerset:
    name: Feature powerset (nightly)
    runs-on: ubuntu-latest
    if: github.event_name == 'schedule'

    # Not on the pull-request path. The crate has 29 features, so an uncapped
    # powerset is 292,672 builds -- not slow, unrunnable. `--depth 2` is 263,
    # and pairwise is where feature-interaction bugs actually live: a feature
    # that breaks alone is caught by the `features` job, and one that breaks
    # only in a specific trio is rare enough not to be worth three orders of
    # magnitude. Even 263 is too much to put in front of every pull request,
    # so it runs once a night. Raise the depth to 3 (1,599 builds) when
    # chasing one.
    #
    # `--skip database` on top of the pinned driver: `db-postgres` already
    # enables it, so including it again only doubles the subsets.
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1
        with:
          toolchain: stable

      - uses: taiki-e/install-action@a2a5f6e99e1a31540baa0468acfa302cff0f359f # v2.86.4
        with:
          tool: cargo-hack

      - name: All feature pairs
        run: cargo hack build --feature-powerset --depth 2 --features db-postgres --skip database,db-sqlite,db-mysql --exclude-all-features --keep-going

  scaffold:
    name: Scaffold and compile
    runs-on: ubuntu-latest

    # What `arc new` writes has to compile. There used to be four application
    # packages under `examples/` making this point by existing; they were empty,
    # so they made it only in the manifest comment. Generating the tree here
    # instead means the layout under test is the one the templates produce, and
    # there is no second copy to keep in sync.
    # Three stacks by three drivers. The driver axis is not padding: it picks a
    # different set of `arcature` features and a different migration dialect,
    # and a template that only ever compiled against SQLite is a template that
    # breaks on someone's first `--db postgres`.
    strategy:
      fail-fast: false
      matrix:
        stack: [react, vue, svelte]
        db: [sqlite, postgres, mysql]

    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1
        with:
          toolchain: stable

      - name: Build the CLI
        run: cargo build --bin arc --features cli

      - name: Scaffold
        run: ./target/debug/arc new app --stack ${{ matrix.stack }} --db ${{ matrix.db }}

      # `arc new` writes a normal `arcature = "<version>"` dependency, which is
      # correct and will resolve the moment the crate is published. It is not
      # published yet, so
      # until then CI has to point the generated project at the working tree it
      # was generated from -- otherwise this job would only ever test the last
      # release, which is the one thing it is not here to test.
      #
      # Delete this step after the first `cargo publish`.
      - name: Point the generated project at this checkout
        run: |
          {
            echo ""
            echo "[patch.crates-io]"
            echo 'arcature = { path = ".." }'
          } >> app/Cargo.toml

      - name: Compile it
        working-directory: app
        run: cargo build

      # Only on the SQLite leg. The scaffold's smoke test boots the application,
      # and on the other two drivers that means connecting to a server this job
      # does not run. Compiling is the gate for those; SQLite is where the test
      # suite the template ships actually gets executed.
      - name: Its own test suite
        if: matrix.db == 'sqlite'
        working-directory: app
        run: cargo test

  deny:
    name: Licences and advisories
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1

  book:
    name: Guide
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: taiki-e/install-action@a2a5f6e99e1a31540baa0468acfa302cff0f359f # v2.86.4
        with:
          tool: mdbook

      - name: Build the guide
        run: mdbook build docs

  publish-dry-run:
    name: cargo publish --dry-run
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1
        with:
          toolchain: "1.97.1"

      # `arcature-macros` first, and unconditionally. It depends on nothing this
      # workspace publishes, so its dry run is meaningful today, and it is the
      # crate that has to go out first anyway.
      - name: cargo publish --dry-run -p arcature-macros
        run: cargo publish --dry-run -p arcature-macros
        env:
          RUSTUP_TOOLCHAIN: "1.97.1"

      # `arcature` cannot be dry-run yet, and that failure says nothing about
      # this crate. Packaging resolves `arcature-macros = { version = "=0.1.0",
      # path = "macros" }` against the registry -- `--no-verify` skips the
      # build, not the resolve -- so before the first publish the step can only
      # report
      #
      #     no matching package named `arcature-macros` found
      #
      # This is what turned every run on `main` red. A required job that cannot
      # pass would hold `CI success` permanently red and make the gate
      # meaningless, so the step asks the sparse index whether macros exists and
      # runs only when the answer is yes. It begins working on its own the
      # moment the first publish lands -- nothing here needs revisiting then.
      - name: cargo publish --dry-run -p arcature
        run: |
          if curl --proto '=https' --tlsv1.2 --silent --show-error --fail \
                  --location https://index.crates.io/ar/ca/arcature-macros \
                  --output /dev/null; then
            cargo publish --dry-run -p arcature
          else
            echo "::notice title=arcature dry run skipped::arcature-macros is not on crates.io, so packaging arcature would fail on the registry resolve rather than on anything in this tree."
          fi
        env:
          RUSTUP_TOOLCHAIN: "1.97.1"

  # ---------------------------------------------------------------------------
  # The one check branch protection requires.
  #
  # `main` requires exactly one status check: this job. Listing the real jobs
  # individually would work today and rot tomorrow -- `test` and `scaffold` are
  # matrices, so their check names carry their matrix values (`Test (stable)`,
  # `Scaffold and compile (react, sqlite)`), and adding a stack, a driver or a
  # toolchain silently adds a check that protection does not require. A leg that
  # nobody requires is a leg that can go red without blocking a merge.
  #
  # `if: always()` is what makes this a gate rather than a formality. Without
  # it the job is skipped when anything it needs fails, and a skipped required
  # check reads to the merge button as "not failing".
  #
  # `powerset` is deliberately absent: it only runs on the schedule, so
  # requiring it would leave every pull request waiting on a job that never
  # starts.
  ci-success:
    name: CI success
    if: always()
    needs:
      - test
      - drivers
      - features
      - scaffold
      - deny
      - book
      - publish-dry-run
    runs-on: ubuntu-latest
    steps:
      - name: Fail unless every required job succeeded
        # `cancelled` and `skipped` are failures here for the same reason
        # `always()` is above: neither one is a green build, and treating
        # either as passing hands the merge button a result nobody produced.
        if: >-
          contains(needs.*.result, 'failure')
          || contains(needs.*.result, 'cancelled')
          || contains(needs.*.result, 'skipped')
        run: |
          echo "One or more required jobs did not succeed."
          exit 1

      - name: Report
        run: echo "Every required job succeeded."