# The upload-artifact least common ancestor decides your paths

> The upload-artifact least common ancestor comes from your search paths, not your matched files. That is why a downloaded artifact is too shallow.

Source: https://latchkey.dev/learn/github-actions/gha-artifact-path-glob-empty  
Updated: 2026-09-20

The upload-artifact least common ancestor is the directory the action treats as the root of your archive, and it is derived from the search paths you wrote rather than from the files they matched. Get that distinction wrong and the downloaded artifact is one directory shallower or deeper than the script consuming it expects.

## What this error means

Nothing fails. The upload step is green, the download step is green, and then a script two jobs later cannot find a file it has always found. When you list the downloaded directory the files are all present, at the wrong depth: a leading directory has disappeared, or two unrelated trees have been merged, or a single file is sitting alone at the root with no parent. The log did tell you, in two `info` lines most people scroll past, because they arrive before the file count and look like progress output rather than a decision.

```Reconstructed from the two core.info calls in actions/upload-artifact src/shared/search.ts (v7.0.1); the path in the second line is a runtime value
Multiple search paths detected. Calculating the least common ancestor of all paths
The least common ancestor is /home/runner/work/app/app. This will be the root directory of the artifact
```

## Common causes

### You added a second path to a step that had one

This is the common regression. A step uploading `dist` gains a second line for `reports`, the search path count goes from one to two, the root climbs to their shared parent, and every consumer of the first artifact now needs an extra `dist/` in front of its paths. Nothing in the diff says so.

### You uploaded one named file and expected its directory

A single file whose search path equals the file itself is special-cased to use its parent as the root, which puts the file at the top of the archive with no directory around it. Consumers that expected `coverage/lcov.info` get `lcov.info`.

### Your paths have no useful shared parent

Mixing a workspace-relative path with an absolute one, or two absolute paths under different top-level directories, pushes the ancestor up to the filesystem root. The archive then carries full absolute paths, which is technically correct and almost never what anyone wanted.

### A wildcard moved the prefix you were relying on

Changing `packages/app/dist` to `packages/*/dist` moves the non-magic prefix from `packages/app/dist` up to `packages`, because everything from the first wildcard onwards stops being part of the search path. The matched files can be identical and the archive layout still changes.

## How to fix it

### Read the two info lines before you change anything

1. Open the upload step in the run and expand it.
2. Look for the line beginning The least common ancestor is; it names the root explicitly.
3. If that line is absent, there was only one search path and the root is that path.
4. Compare the root against the paths your consumer expects, then change the input rather than the consumer.

### Stage into one directory and upload that

The most durable fix is to stop relying on the calculation at all. Copy what you want into a single staging directory, in the shape you want it downloaded, and upload that one path. The archive then has exactly one plausible root, and adding a fourth thing to the artifact later cannot move it.

```.github/workflows/ci.yml (illustrative)
- name: Stage the artifact
        run: |
          mkdir -p staging/dist staging/reports
          cp -r build/output/. staging/dist/
          cp -r coverage/. staging/reports/
      - uses: actions/upload-artifact@v7
        with:
          name: build
          path: staging
```

### Or keep multiple paths and pin the level you need

If you would rather keep the multi-path form, give the paths a shared parent you chose on purpose and let the ancestor land on it. Writing the parent out as a comment next to the input is cheap and stops the next person from adding a path outside it.

```.github/workflows/ci.yml (illustrative)
- uses: actions/upload-artifact@v7
        with:
          name: build
          # both paths are under build/, so build/ is the archive root
          path: |
            build/output
            build/meta.json
```

### Check the shape once, in the job that downloads it

Add a listing immediately after the download while you are settling this, and leave it in if the artifact crosses a job boundary. It turns a confusing failure three steps later into an obvious one at the point where the layout stopped being what you assumed.

```.github/workflows/deploy.yml (illustrative)
- uses: actions/download-artifact@v8
        with:
          name: build
          path: incoming
      - run: find incoming -maxdepth 2 -type d | sort
```

## How to prevent it

- Upload one staged directory rather than several live paths when the layout matters.
- Treat a change to the `path` input as a breaking change for every consumer of that artifact.
- Keep a `find` or `ls` immediately after any download that crosses a job boundary.
- Watch the non-magic prefix when you introduce a wildcard into a path that already worked.

## The three branches that decide the root

All of the behavior is in one function, `findFilesToUpload`. It globs your `path` input, filters out directories, and then picks a root by one of three rules. The middle branch is the surprising one: a single file whose search path is the file itself loses its parent directory entirely and lands at the archive root.

Read `globber.getSearchPaths()` as the non-magic prefix of each pattern you wrote. `build/output/**/*.js` has one search path, `build/output`. Two patterns give two search paths even when they match overlapping files, and that is what flips the function into the first branch.

```actions/upload-artifact, src/shared/search.ts (v7.0.1), condensed
const searchPaths: string[] = globber.getSearchPaths()

if (searchPaths.length > 1) {
  info(`Multiple search paths detected. Calculating the least common ancestor of all paths`)
  const lcaSearchPath = getMultiPathLCA(searchPaths)
  info(`The least common ancestor is ${lcaSearchPath}. This will be the root directory of the artifact`)
  return {filesToUpload: searchResults, rootDirectory: lcaSearchPath}
}

if (searchResults.length === 1 && searchPaths[0] === searchResults[0]) {
  return {filesToUpload: searchResults, rootDirectory: dirname(searchResults[0])}
}

return {filesToUpload: searchResults, rootDirectory: searchPaths[0]}
```

## What each input shape gives you

The table is those three branches applied to the inputs people actually write. It is worth reading the middle row twice. A single named file produces an archive with that file at the top and no directory at all, which is almost always what you want and is also why adding a second path to the same step changes the layout of the first one.

The last row is the case that gets reported as a bug. Two absolute paths with nothing in common resolve to `/` as the ancestor, and the archive then carries the whole absolute path of every file. The action documents exactly this in a comment on `getMultiPathLCA`: the patterns `/foo/` and `/bar/` return `/`.

| What you wrote in path | Search paths | Archive root |
| --- | --- | --- |
| `build/output` | `build/output` | `build/output`, structure below it kept |
| `build/output/app.js` | `build/output/app.js` | `build/output`, so app.js sits at the top |
| `build/output/**/*.js` | `build/output` | `build/output`, structure below it kept |
| `build/app` and `build/meta` | both | `build`, so the archive gains an app and a meta level |
| `/tmp/one` and `/var/two` | both | `/`, so every file carries its absolute path |

## Where the common advice goes wrong

The version of this you will find in most places says that the action strips the least common ancestor of the matched files. That is not what the code does, and the difference is not academic. If it were computed from the files, adding a file deep in one tree would change the root and the layout of everything else in the archive. Because it is computed from the search paths, the layout is stable under changes to your build output and unstable under changes to your `path` input, which is the opposite of what people assume when they debug it.

It also explains a case that otherwise looks like a bug. A glob that currently matches files in only one subdirectory still roots the archive at the non-magic prefix of the pattern, not at that subdirectory, so the archive keeps a level that appears empty of alternatives. That is correct and it is stable, which is what you want from something a consuming script depends on.

One more line from the same function is worth knowing about. Uploads are case insensitive, and the action writes `Uploads are case insensitive:` followed by the path when two matched files differ only in case. That is an `info` line too, and the second file silently replaces the first.

> If the upload found nothing at all rather than finding the wrong shape, that is a different message and a different page: see [no files were found with the provided path](/learn/github-actions/upload-artifact-no-files-were-found-v4).

## Why there is no recorded run on this page

There is no failure to record. Every step in the run this page is about reports success, and the defect only becomes visible when something opens the downloaded directory and looks for a path that is not there. A screenshot of a green job would be evidence of nothing, and the log lines that carry the decision are already quoted above from the function that writes them.

The evidence that would actually help is the shape of the resulting archive, and that is a property of your `path` input rather than of any machine. You can get it in one command without a runner: run the same glob locally and compare the prefix of each pattern against the files it matched.

## FAQ

### What is the root directory of a GitHub Actions artifact?

It is whatever `findFilesToUpload` returns as `rootDirectory`, which is the single search path when there is one, its parent when the search path is a single file, and the least common ancestor of the search paths when there is more than one. Everything in the archive is stored relative to it.

### Why is a directory missing from my downloaded artifact?

Because it was the archive root, so every path in the artifact starts below it. That happens when your `path` input pointed at that directory, or pointed at a single file inside it. Upload the parent instead, or stage the files into the shape you want before uploading.

### Does adding a second path change the layout of the first?

Yes, and this catches people out. One search path roots the archive at that path; two search paths root it at their common ancestor, which is higher. The files matched by the first path are then stored one level deeper than they were before you added the second.

### How do I keep the full directory structure in an artifact?

Give the step exactly one path, high enough to contain everything you want, and let the glob find the rest. `path: build` keeps everything under `build` with `build` as the root. Listing several sibling paths under `build` gives you the same files with `build` as the root as well, which is often the surprise.

## References

- [actions/upload-artifact src/shared/search.ts: findFilesToUpload and getMultiPathLCA](https://github.com/actions/upload-artifact/blob/main/src/shared/search.ts)
- [actions/toolkit: validateRootDirectory and how each file is stored relative to the root](https://github.com/actions/toolkit/blob/main/packages/artifact/src/internal/upload/upload-zip-specification.ts)
- [actions/upload-artifact: the path input and its multiple-path form](https://github.com/actions/upload-artifact#inputs)
- [Storing and sharing data from a workflow](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/download-workflow-artifacts)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
