# Publishing

What one sf publish actually does, which files it sends, how change-only uploads work, and what the receipt tells you.

After this page you can publish a folder from the CLI, the dashboard, or the API, predict which files make it and which get dropped, and read a receipt well enough to know whether your site is live.

## The mental model

One publish is four steps.

1. **Declare.** The client lists every file it wants the version to hold, with a size and usually a `sha256`.
2. **Upload.** The server answers with upload targets for the bytes it does not already have. Everything else is reused.
3. **Finalize.** The version compiles into its served form and becomes `ready`.
4. **Promote.** The `live` pointer moves to it.

`sf publish` runs all four and prints a URL. `POST /v1/publish` collapses all four into one request and returns a receipt. The steps are separately addressable when you need them, which is how a resumed upload and a promote-later flow work.

## Publish

CLIDashboardAPI

```
sf publish ./dist
```

With no argument it publishes the current directory, building it first if it detects a project. `--message` records a changelog entry on the version. `--target preview` creates a ready version without moving `live`.

```
sf publish ./dist --message "Fix pricing table"
sf publish --target preview --json
```

**Drop** takes a folder, a `.zip`, or a single `index.html`. Drag it onto the window, or use **Choose a folder** or **Choose files**. Pick a **Team**, a **Destination** of **New space** or **Existing space**, and for an existing Space either **Add & update** or **Replace all**. Progress runs **Prepare → Upload → Build → Go live**.

For a Space you already have open, the topbar **Publish** button asks **How do you want to publish?** and offers **Publish from Git**, **Drop files**, **Use the Spacefast CLI**, or an agent.

```
curl -X POST https://api.spacefast.com/v1/publish \
  -H "Authorization: Bearer sfa_xxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/zip" \
  --data-binary @site.zip
```

`createPublish` also takes a JSON manifest, multipart form data, a single HTML document, or raw bytes for one file.

## What gets uploaded

Publishing walks the directory and builds a manifest. Three things can happen to a path.

### Dropped without complaint

Some paths are never worth failing a whole publish over, so they are dropped and counted instead. Exact names at any depth:

```
.DS_Store  .gitignore  .npmrc  .netrc
.env  .env.local  .env.production  .env.development  .env.test
_routes.json  .spacefast/state.json  .stattic/state.json
```

Whole directories at any depth: `.git`, `.hg`, `.svn`, `.ssh`, `.aws`, `.cache`, `.turbo`, `.vercel`, `.netlify`, `.wrangler`, `node_modules`, and `.next/cache`.

Anything that looks like a credential is dropped the same way: names starting `.env.`, SSH private keys (`id_rsa`, `id_ed25519`, and friends), files named like a private key, and any `.pem`, `.key`, `.p12`, or `.pfx`.

Symlinks are skipped, not followed. The CLI prints `Warning: skipped symlink <path> (symlinks are not published).` for each one.

`_headers` and `_redirects` are convention files and always publish.

### Rejected with an error

These fail the publish rather than getting dropped, because silently ignoring them would change what your site does.

| Path | Error code |
| --- | --- |
| Untrimmed, starting `/`, containing `\` or `//`, or not canonical | `invalid_file_path` |
| A segment ending in `.` or a space | `invalid_file_path` |
| Canonical path over 1,024 bytes | `path_too_long` |
| Named `.htaccess` or `.user.ini` | `static_control_file_not_supported` |
| Under a runtime control prefix, such as a top-level `__sf` or `__spacefast` | `static_runtime_control_path_not_supported` |
| Two entries that canonicalize to the same path | `manifest_duplicate_path` |

Two artifacts claiming the same request route also stop the publish, so a lookup-order coin flip never decides which one visitors get.

### Size and count

| Limit | Value | Error |
| --- | --- | --- |
| Files per version | 100,000, 1,000 for an anonymous publish | `manifest_too_many_files` |
| Largest single file | 1 GiB, 50 MiB for an anonymous publish | `version_file_too_large` |
| Total bytes per version | 2 GiB | `version_total_bytes_exceeded` |
| Manifest request body | 16 MiB | `manifest_body_too_large` |
| Inline archive body | 128 MiB | `publish_archive_too_large` |

The file count is a platform ceiling: every claimed plan gets the same 100,000. Anonymous publishes run a tighter table.

## Only changed bytes travel

Every declared file carries a `sha256`. The server compares your declaration against the version you are replacing, keeps the bytes it already has, and asks for the rest. The upload instructions come with a `summary` counting exactly that:

```
{ "upload": 3, "reused": 41, "ignored": 0 }
```

Deleting a file means leaving it out of the declaration. There is no delete list. That has one consequence worth knowing:

| Mode | What your file list means |
| --- | --- |
| `snapshot` | The complete site. Paths you leave out are removed |
| `additive` | Additions and replacements. Paths you leave out stay |

`sf publish` defaults to `snapshot`. `POST /v1/publish` defaults to `additive`. Set it with `--publish-mode` or the `publishMode` field.

## Nothing changed

If your declaration reproduces the live version byte for byte, no version is created. The response comes back with a null version id and one info diagnostic:

```
oop_publish
```

That diagnostic is the only signal. There is no separate boolean.

One catch. A file declared without a `sha256` cannot prove it is unchanged, so a manifest missing any hash always materializes a new version.

## Waiting, or not

A publish holds one HTTP request open while the work runs, up to a 240 second ceiling. Past that the receipt degrades to `next.action` of `poll` and the work carries on in the background. You did not lose the publish.

Add `?async=1` to skip the wait and get an operation handle immediately. Only `async=1` and `async=true` do that. `async=0`, `async=false`, and leaving it off all wait.

## Reading the receipt

The parts you actually read:

| Field | What it tells you |
| --- | --- |
| `space.id`, `space.slug` | Which Space this landed in |
| `space.liveUrl` | The URL to open |
| `version.id`, `version.ref`, `version.number` | The version this publish created |
| `version.status` | `ready` means finalized |
| `version.immutableUrl` | The permanent per-version URL, null while finalizing |
| `activation.outcome` | `activated`, `unpromoted`, `superseded`, or `pending` |
| `next.action` | `done`, `upload`, `finalize`, or `poll` |
| `next.url` | Where to go for that action |
| `diagnostics` | Warnings, dropped paths, and `noop_publish` |
| `operation` | The handle to poll when finalize kept running |

`next.action` is the field to branch on. `done` means there is nothing left to do, and the `hint` says why, for example `Live. Nothing left to do.` An `unpromoted` outcome with `done` means the version is ready and you need to promote it yourself.

## What the API accepts

`POST /v1/publish` sniffs the content type.

| Content type | Body |
| --- | --- |
| `application/json` | A file manifest plus metadata |
| `application/zip`, `application/x-tar`, `application/gzip` | A project archive |
| `multipart/form-data` | One binary part per file, or one `archive` part, plus an optional `payload` JSON part |
| `text/html` | A single document, published as `index.html` |
| `application/octet-stream` | Raw bytes for one file, path from the `x-spacefast-path` header |

## Static zip or a build

An archive takes one of two paths, decided from the file names alone without unpacking it.

A zip publishes directly as static files when it contains an `index.html` **and** no build markers. A build marker is a lockfile, a workspace root file, a framework config file, or a `vercel.json`, `netlify.toml`, or `wrangler.toml`. A `package.json` next to your `index.html` is enough to make it a build.

Everything else becomes a Build that installs and builds your project, then publishes the output. Tar and tar.gz are always builds, only zip has the static path.

## When a publish fails

| Code | What happened | Fix |
| --- | --- | --- |
| `manifest_too_many_files` | More files than your plan allows | Publish a built output directory, not a source tree |
| `version_file_too_large` | One file over the per-plan ceiling | Host the asset elsewhere, or move up a plan |
| `version_total_bytes_exceeded` | The version is over 2 GiB | Split the site, or drop unused assets |
| `manifest_duplicate_path` | Two entries canonicalize to one path | Two files differ only by encoding or case folding. Rename one |
| `invalid_file_path` | A path Spacefast cannot canonicalize | Remove backslashes, doubled slashes, and trailing dots or spaces |
| `static_control_file_not_supported` | An `.htaccess` or `.user.ini` shipped | Delete it. Use `_redirects` and `_headers` instead |
| `unsupported_publish_media_type` | The content type is not one of the five above | Send a zip or a JSON manifest |
| `publish_archive_compression_ratio_exceeded` | The archive expands over 100 times its size | Repack without the zip bomb shaped content |
