# CI Integration

Run Asset Naming Manager on every pull request, publish the HTML report as a build artifact, and fail
the build when the naming gets worse.

---

## The command

```bash
Unity -batchmode -quit -nographics -logFile - -projectPath . \
  -executeMethod AssetNamingManager.NamingManagerCI.Run \
  -namingManagerReport Reports/naming.html \
  -namingManagerFailOn error
```

| Argument | Provided by | Meaning |
|---|---|---|
| `-batchmode` | Unity | No editor window, no interactive dialogs |
| `-quit` | Unity | Close the editor when the method returns |
| `-projectPath <path>` | Unity | The project to open |
| `-executeMethod <method>` | Unity | The static method to run. Ours is `AssetNamingManager.NamingManagerCI.Run` |
| `-logFile -` | Unity | Stream the editor log to stdout. Add this or you will be debugging blind. |
| `-nographics` | Unity | Skip the graphics device. Safe for a naming scan, which never renders. |
| `-namingManagerReport <path>` | Asset Naming Manager | Where to write the report. Optional |
| `-namingManagerFailOn <severity>` | Asset Naming Manager | `info`, `warning`, `error` or `none`. Optional |

`Run` calls `EditorApplication.Exit` itself with the code from the table below, so that is the code
your job sees. `-quit` above is a belt-and-braces: harmless, and it still closes the editor in the
case where the method somehow never reaches its own exit.

**`-namingManagerReport`** is a path relative to the **project root** — the folder that holds `Assets/`,
not whatever directory your shell happened to be in. An absolute path is used as given. The format
is taken from the file extension: `.html`, `.json`, `.md` or `.csv`. An extension it does not
recognise, or none at all, is a configuration error and exits `2` rather than guessing. Omit the
switch and the run writes HTML into the rule set's `reportFolder` (default `NamingManagerReports`),
under a timestamped name such as `naming-2026-09-17-1432.html`.

**`-namingManagerFailOn`** overrides the rule set's own `ciFailOn`. Omit it and `ciFailOn` decides, so
the gate can travel with the convention instead of living in your CI job's YAML. The run prints
which of the two it used.

One run writes one report. If you want HTML for humans and JSON for a dashboard, run the gate twice
or write a custom entry point — see "A custom entry point" below.

The scan is read-only. It never renames anything.

---

## Which rule set CI uses

Not the one you picked in `Edit ▸ Project Settings ▸ Asset Naming Manager`. That choice is stored in
`EditorPrefs`, per machine and per checkout, and there is no way to commit it.

So on a fresh build agent, where no such preference exists, the run falls back to **auto-discovery**:
it searches the project for `NamingRuleSet` assets, sorts them by asset name, and takes the first.
With one rule set in the repository — which is what you want anyway — that is exactly the rule set
you committed, and CI and your machine agree.

With two, they can disagree silently, and you will lose an afternoon to it. If you genuinely need
more than one convention in one project, do not leave it to the sort order: write a custom entry
point that loads a rule set by path, as the example further down does.

A missing rule set is not a naming failure. The run says so on the Console and exits `2`.

---

## Exit codes

| Code | Meaning | What to do |
|---|---|---|
| `0` | Clean. Nothing at or above the gate severity — or the gate was `none`. | Nothing. |
| `1` | The gate failed. There are findings at or above the gate. | Read the report artifact. |
| `2` | Misconfigured. The run never reached a verdict about your naming. | Fix the job or the rule set. This is not a naming failure. |

Exit `2` covers every way the run can fail to be meaningful:

- The project has no `NamingRuleSet` at all.
- `NamingRuleSet.Validate()` returned anything — an invalid regex, an impossible length range, two
  rules with the same name, a preferred folder with no allowed folders, a gate that can never fire.
  Every one of those is printed to the Console first.
- A switch was given without a value, or swallowed the next switch (`-namingManagerReport -quit` is the
  classic typo, and it is caught rather than writing a report to a file called `-quit`).
- `-namingManagerFailOn` was given something that is not `info`, `warning`, `error` or `none`.
- `-namingManagerReport` had an extension the exporter does not know.
- The report could not be written — a read-only workspace, usually.
- Anything else threw. The exception is logged and the run still exits `2`, never `1`.

Treat `2` as a build infrastructure problem, not as a naming problem. A job that silently passes
because the rule set went missing is worse than no job at all, which is why the misconfigured case
gets its own code — and why a rule set that does not validate stops the run instead of producing a
report nobody can trust.

---

## The gate reads backwards. Read this once.

`-namingManagerFailOn` names the **lowest severity that fails the build**.

| Gate | Build fails when the report contains | Strictness |
|---|---|---|
| `none` | never — it scans and reports, and always exits `0` | No gate at all |
| `error` | at least one Error | Loosest |
| `warning` | at least one Error or Warning | Middle |
| `info` | any finding at all | Strictest |

So `-namingManagerFailOn error` is the *permissive* setting and `-namingManagerFailOn info` is the
*strict* one. Nearly everybody gets this backwards on the first read. Start at `error`.

`none` is the exception that is not a severity: the scan still runs, the report is still written and
published, and the job cannot fail on naming. It is how you land the job on a branch where nothing
is allowed to block a merge yet. A misconfigured run still exits `2` under `none` — that is the
point, it proves the job is alive.

---

## Rolling the gate out gradually

Turning a strict gate on for a project that has never had one produces a red build on day one, an
argument on day two, and a deleted job on day three. Do it in four moves instead.

There are two independent dials, and you turn them in this order:

1. **Rule severity** — how loudly each individual rule complains. Start every new rule at `Info`.
2. **The CI gate** — which severities are allowed to fail the build.

| Stage | Rule severities | CI job | Gate | Effect |
|---|---|---|---|---|
| 1. Observe | All new rules at `Info` | Runs on every PR | `none` | Everyone can see the report. Nothing is blocked. |
| 2. Hold the line | Raise the rules you agree on to `Warning`; illegal characters to `Error` | Gate on | `error` | Only genuinely broken names, such as a space or a non-ASCII character, block a merge. |
| 3. Tighten | Rules in clean areas at `Warning` | Gate on | `warning` | Warnings now block. Do this only once the warning count is near zero. |
| 4. Lock it | Everything settled | Gate on | `info` | Nothing new gets in at all. Most teams stop at stage 3. |

`-namingManagerFailOn none` is the cleanest way to run stage 1: the job is genuinely green, and a red
build still means something — the job itself broke.

The other way is to leave the gate on and make the *step* non-fatal, which you want when you would
rather see the failure marked somewhere:

- GitHub Actions: `continue-on-error: true` on the step.
- GitLab CI: `allow_failure: true` on the job.
- Jenkins: `returnStatus: true` on the `sh` step, then decide what to do with the code yourself.

The difference matters: with `none` you cannot tell a clean project from a dirty one by the job
status, only by reading the report. With a real gate plus a non-fatal step, the status carries the
signal and nobody is blocked. Pick whichever your team actually looks at.

Move a stage only when the report has been quiet for a week. If a stage produces a wall of red,
you moved too early — go back one stage and scope the noisy rule with `pathFilters` instead. See
the "do not boil the ocean" section in `QuickStart.md`.

---

## GitHub Actions

A self-hosted runner with Unity already installed, which is the arrangement most studios end up
with.

```yaml
name: Asset naming

on:
  pull_request:
    paths:
      - 'Assets/**'
      - '.github/workflows/asset-naming.yml'

jobs:
  naming:
    runs-on: [self-hosted, unity]
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v4
        with:
          lfs: true

      - name: Run Asset Naming Manager
        id: cop
        continue-on-error: false          # stage 1: use -namingManagerFailOn none instead
        run: |
          mkdir -p Reports
          "$UNITY_PATH/Unity" \
            -batchmode \
            -quit \
            -nographics \
            -logFile - \
            -projectPath "$GITHUB_WORKSPACE" \
            -executeMethod AssetNamingManager.NamingManagerCI.Run \
            -namingManagerReport Reports/naming.html \
            -namingManagerFailOn error

      - name: Publish the report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: asset-naming-report
          path: Reports/naming.html
          retention-days: 30
          if-no-files-found: warn
```

`if: always()` on the upload step is the important line. Without it the artifact is skipped
exactly when the build failed, which is the only time anyone wants to read it.

`Reports/` is deliberately outside `Assets/`. A report written inside `Assets/` gets imported by
Unity, and then reported on by the next scan.

### With a hosted runner

If you use a container image that ships the editor, replace the run step's executable with the
editor path inside the image and add your licensing step before it. The Asset Naming Manager arguments
do not change. With `game-ci/unity-builder`, pass the tool's arguments through that action's
`customParameters` input and keep `-executeMethod AssetNamingManager.NamingManagerCI.Run`.

### Failing the job but keeping the annotation

If you want the naming failure to be visible without blocking the merge queue, use
`continue-on-error: true` on the run step and then add an explicit check:

```yaml
      - name: Report the naming gate result
        if: always()
        run: |
          if [ "${{ steps.cop.outcome }}" = "failure" ]; then
            echo "::warning::Asset naming gate failed. See the asset-naming-report artifact."
          fi
```

---

## GitLab CI

```yaml
stages:
  - verify

asset-naming:
  stage: verify
  image: $UNITY_IMAGE
  timeout: 30 minutes
  rules:
    - changes:
        - Assets/**/*
  allow_failure: false            # stage 1: use -namingManagerFailOn none instead
  before_script:
    - mkdir -p Reports
  script:
    - >
      "$UNITY_PATH/Unity"
      -batchmode
      -quit
      -nographics
      -logFile -
      -projectPath "$CI_PROJECT_DIR"
      -executeMethod AssetNamingManager.NamingManagerCI.Run
      -namingManagerReport Reports/naming.html
      -namingManagerFailOn error
  artifacts:
    when: always
    expire_in: 30 days
    paths:
      - Reports/naming.html
```

`when: always` on the artifacts block is the GitLab equivalent of `if: always()`. Without it, a
failed job publishes nothing.

GitLab renders a single-file HTML artifact through GitLab Pages when you configure it as a pages
job. The report is self-contained with no external assets, so it works either way — as a
downloadable artifact or served directly.

---

## Jenkins

A declarative pipeline stage. `returnStatus: true` hands you the exit code instead of throwing, so
you can tell code `1` (naming) apart from code `2` (misconfigured).

```groovy
stage('Asset naming') {
    steps {
        script {
            sh 'mkdir -p Reports'

            int code = sh(
                returnStatus: true,
                script: '''
                    "$UNITY_PATH/Unity" \
                      -batchmode \
                      -quit \
                      -nographics \
                      -logFile - \
                      -projectPath "$WORKSPACE" \
                      -executeMethod AssetNamingManager.NamingManagerCI.Run \
                      -namingManagerReport Reports/naming.html \
                      -namingManagerFailOn error
                '''
            )

            if (code == 2) {
                error('Asset Naming Manager is misconfigured. Check the active rule set.')
            } else if (code == 1) {
                // Stage 1 and 2: mark unstable instead of failing outright.
                unstable('Asset naming gate failed. See the published report.')
            }
        }
    }
    post {
        always {
            archiveArtifacts artifacts: 'Reports/naming.html',
                             allowEmptyArchive: true,
                             fingerprint: true

            publishHTML target: [
                reportDir            : 'Reports',
                reportFiles          : 'naming.html',
                reportName           : 'Asset naming',
                keepAll              : true,
                alwaysLinkToLastBuild: true,
                allowMissing         : true
            ]
        }
    }
}
```

`publishHTML` comes from the HTML Publisher plugin and puts a clickable "Asset naming" link in the
build sidebar. Because the report is a single self-contained file with no external CSS or
JavaScript, Jenkins' content security policy does not strip it the way it strips multi-file
reports.

Swapping `unstable(...)` for `error(...)` is how you move from stage 2 to stage 3.

---

## Publishing the HTML report

The same three rules apply on every CI system.

1. **Write it outside `Assets/`.** `Reports/naming.html` is a good choice. Anything inside
   `Assets/` gets imported by Unity and then reported on by the next scan. The rule set's
   `reportFolder` field, default `NamingManagerReports`, exists for the same reason: it is where a run
   with no `-namingManagerReport` puts its report, and it too sits next to `Assets/` rather than inside
   it. Add whichever folder you use to `.gitignore`.
2. **Publish it even when the step fails.** `if: always()`, `when: always`, `post { always { } }`.
   The failed build is the one people need to read.
3. **Keep it.** 30 days of retention is enough to answer "when did this start?" without paying for
   storage forever.

The HTML report is one file with no external assets, so it works as a build artifact, as an email
attachment, and offline on a laptop.

For a machine-readable trend line you want JSON as well, and the shipped entry point writes exactly
one report per run. Either run it a second time with `-namingManagerReport Reports/naming.json`, or —
cheaper, since it scans once — use a custom entry point that calls `ReportExporter.Write` for each
format you want. `ScanReport.HealthScore` is the number worth plotting: it is relative to project
size, so it does not drift just because the project grew.

---

## A custom entry point

Use the shipped `AssetNamingManager.NamingManagerCI.Run` unless you need something it does not do — the
usual reasons are more than one report format from a single scan, a rule set chosen by path rather
than by auto-discovery, or a gate scoped to part of the project. Then write your own static method
and point `-executeMethod` at it. Everything below is public API.

```csharp
using System.Collections.Generic;
using System.IO;
using AssetNamingManager;
using UnityEditor;
using UnityEngine;

public static class StudioNamingGate
{
    const string RuleSetPath = "Assets/AssetNamingManager/Settings/NamingRules.asset";

    // Unity -batchmode -quit -nographics -logFile - -projectPath . \
    //   -executeMethod StudioNamingGate.Run
    // Loading by path is the point: it does not depend on auto-discovery.
    public static void Run()
    {
        var ruleSet = AssetDatabase.LoadAssetAtPath<NamingRuleSet>(RuleSetPath);
        if (ruleSet == null)
        {
            Debug.LogError("[naming] No rule set at " + RuleSetPath);
            EditorApplication.Exit(2);
            return;
        }

        List<string> configProblems = ruleSet.Validate();
        if (configProblems.Count > 0)
        {
            foreach (var problem in configProblems) Debug.LogError("[naming] " + problem);
            EditorApplication.Exit(2);
            return;
        }

        var options = new ScanOptions
        {
            includeFolders = true,
            includeDuplicates = true,
            onProgress = null          // no progress callback in batch mode
        };

        ScanReport report = NamingScanner.Scan(ruleSet, options);
        Debug.Log("[naming] " + report.Summary());

        // One scan, three formats. ReportExporter.Write creates the folder and resolves a relative
        // path against the working directory, not the project root — unlike -namingManagerReport. Build
        // an absolute path from Application.dataPath if your agent starts somewhere else.
        Directory.CreateDirectory("Reports");
        ReportExporter.Write(report, ReportFormat.Html, "Reports/naming.html");
        ReportExporter.Write(report, ReportFormat.Json, "Reports/naming.json");
        ReportExporter.Write(report, ReportFormat.Markdown, "Reports/naming.md");

        foreach (var pair in ScanReport.Sorted(report.CountBy(v => v.ruleName), 5))
            Debug.Log("[naming] " + pair.Value + " x " + pair.Key);

        bool failed = report.FailsGate(ruleSet.ciFailOn);
        Debug.Log("[naming] Health " + Mathf.RoundToInt(report.HealthScore) +
                  "/100 (" + report.HealthLabel + "). Gate: " + (failed ? "FAILED" : "passed"));

        EditorApplication.Exit(failed ? 1 : 0);
    }
}
```

Three things to get right:

- **Exit on every path.** `-quit` on its own exits `0`, so a method that returns without calling
  `EditorApplication.Exit` hands you a green build whatever it found. That is the failure mode to
  fear, not double-exiting: when your method does call `Exit`, it runs first and its code is the one
  the job sees, so `-quit` alongside it is harmless.
- **Return the exit code you mean.** `2` for a configuration problem, `1` for a naming failure.
  Collapsing them into `1` makes a broken job look like a naming regression.
- **Never open a dialog.** `EditorUtility.DisplayDialog` in batch mode is a hung build. The shipped
  entry point passes no progress callback for the same reason.

### Gate only part of the project

Useful while you are still at stage 1 everywhere else:

```csharp
var options = new ScanOptions
{
    roots = new[] { "Assets/_Game/Art", "Assets/_Game/UI" },
    includeDuplicates = false          // a scoped scan cannot see project-wide duplicates
};
```

`includeDuplicates` is worth turning off for a scoped scan. Duplicate detection compares every
asset in the scan against every other, so a scan of two folders would only find duplicates within
those two folders and could report a pair as unique when a third copy exists elsewhere.

### Print only what changed in this pull request

```csharp
// paths gathered from `git diff --name-only origin/main...HEAD`
var options = new ScanOptions
{
    explicitPaths = changedAssetPaths,
    includeFolders = false,
    includeDuplicates = false
};

ScanReport report = NamingScanner.Scan(ruleSet, options);
```

`explicitPaths` examines exactly those paths and ignores `roots` entirely. This is the fastest
possible gate and the fairest one: a pull request is judged on what it added, not on what the
project already had. It is also what the import hook uses.

---

## Fixing in CI

You can, and most teams should not.

A bot that renames assets and opens a pull request is easy to build — `NamingFixer.Apply` takes a
`showProgress` flag that you pass `false` in batch mode:

```csharp
ScanReport report = NamingScanner.Scan(ruleSet);
List<FixPlan> plans = NamingFixer.BuildPlans(report.violations);
FixResult result = NamingFixer.Apply(plans, showProgress: false);

Debug.Log("[naming] " + result.Summary());
foreach (var problem in result.problems) Debug.LogWarning("[naming] " + problem);
```

Before you ship that, consider what it means. The Fix Journal that makes a rename revertible lives
in `UserSettings/`, which is per-machine and normally not in the repository, so a CI fix is not
revertible from anybody's History tab. The bot also cannot know that `hero.png` is loaded by
`Resources.Load("hero")` or that a `.cs` rename needs the class renamed too.

A rename is a decision. Let CI report it and let a person press the button.

---

## Troubleshooting a CI run

| Symptom | Cause |
|---|---|
| Exit code `2` on a job that used to work | Read the Console lines above it — the run always says which of the cases under "Exit codes" it hit. A rule set that stopped validating, and a rule set that stopped being committed, are the two common ones. |
| Exit code `2` straight after a rule was added | `Validate()` is now unhappy: most often two rules with the same `displayName`, or a regex that does not compile. The message names the rule. |
| The job passes but finds nothing | `scanRoots` does not cover the folders you care about, or `ignoredPaths` is swallowing them. The summary line tells you how many assets were scanned; if that number is far too small, one of those two is why. The window's Dashboard shows the ignored count as a tile. |
| The job fails on a clean project | Your gate is at `info`, which fails on any finding at all, including `Info` ones. See "The gate reads backwards". |
| No artifact on a failed build | The publish step is missing `if: always()`, `when: always` or `post { always { } }`. |
| The build hangs | A dialog is waiting for input. Add `-batchmode`, and never call `EditorUtility.DisplayDialog` from a CI entry point. In a custom fixer, pass `showProgress: false` to `NamingFixer.Apply`. |
| The report lists the tool's own assets | The `Assets/AssetNamingManager/Editor/**`, `Documentation/**` and `Settings/**` entries were removed from `ignoredPaths`. Put them back. `Demo/**` is deliberately not ignored — add it there, or delete the folder. |
| Findings differ between a developer's machine and CI | Two rule sets in the repository. The active one is an `EditorPrefs` value that cannot be committed, and a machine that has never chosen takes the first by name. See "Which rule set CI uses". |
| The report went somewhere unexpected | `-namingManagerReport` is relative to the project root, not to the working directory. Or you omitted it, and the report is in `NamingManagerReports/` under a timestamped name. |
| The log says nothing useful | Add `-logFile -` so the editor log streams to stdout instead of into a file inside the container. Every line the tool writes is prefixed `[Naming Manager]`. |

---

## See also

- `QuickStart.md` — the first five minutes, and the scoping advice that keeps the gate green.
- `RuleCookbook.md` — rule recipes, including how to scope a rule to new work only.
- `README.md` — the full public API, report formats and the Fix Journal.
