# 07 — The publish output

**Revised:** 2026-08-19. The first version of this file was a rule policing *where* an output
directory may live. The maintainer removed the question instead:

> *"the `.sg_vault/publish` should be the only folder that changes after an `sgit publish`
> command has been executed… this should contain the required details and content for
> publishing in multiple places (GitHub Pages, Local, netlify, S3 bucket, etc…), in a way this
> `.sg_vault/publish` doesn't care where it is published."*

**`sgit publish` takes no target.** It writes one folder, `.sg_vault/publish/`, and nothing
else on disk changes. Deployment is a separate act, performed by whatever puts files on a
host.

---

## 1. What this deletes

The previous version of this file existed to police an output-directory argument. With no
argument there is no question to police, and five things go away at once:

| Was needed | Now |
|---|---|
| containment rule, computed on `realpath` in both directions | **gone** — there is one path, and it is fixed |
| the `--force`-on-an-ancestor disaster (clearing a parent dir would delete `.sg_vault`) | **gone** — nothing outside the folder is ever written or cleared |
| the amplification loop (publish → push → publish doubles the store) | **gone by construction** — `.sg_vault` is in `ALWAYS_IGNORED_DIRS` in every shipped version |
| the "already ignored by `.gitignore`" escape hatch, and its refusal messages | **gone** — no escape hatch is needed from a rule that no longer exists |
| a `.site` / `.sgit/publish/` naming decision | **gone** — no new tracked folder is introduced, so nothing can collide with vault content |

The evidence that motivated the rule still stands and is why the fixed path is the right one:
`sgit push` skips only what `Vault__Ignore` declares (`Vault__Sync__Push.py:765-773`), so any
published folder *outside* `.sg_vault/` would be ordinary content, and publish → push →
publish would double the store on every cycle, silently.

## 2. The output is a finished, target-agnostic artefact

```
.sg_vault/publish/
├── .gitignore                              containing `*` — see §4
├── index.html                              PLAINTEXT  the loader — always sgit's template (§3)
├── cover.json                              PLAINTEXT
├── manifest.json                           PLAINTEXT  objects, commits, plaintext surface
├── sgit_public_read_<64-hex>               PLAINTEXT  only when visibility is public
├── api/openapi.json                        PLAINTEXT  optional (`08`)
├── api/docs/                               PLAINTEXT  optional (`08`)
├── api/vault/read/<vault_id>/bare/…        CIPHERTEXT byte-identical to the store
└── bundles/                                optional (`P6`)
```

**Nothing target-specific goes in it.** No `CNAME`, no `.nojekyll`, no `netlify.toml`, no
`_headers`, no bucket policy, no cache-control metadata. Those are decisions of the place it
lands, and putting any of them here would break invariant I1 — that the published bytes are
identical regardless of destination — which is the property that makes one folder deployable
to four hosts.

That also means a deployer can be **dumb**: `rsync -a`, `aws s3 sync`, `git add`, or a static
server pointed straight at the folder. Nothing needs to interpret the contents.

## 3. The two `index.html` files are in different places and different states

They are easy to conflate — the pack did, briefly — and keeping them apart resolves the
override question rather than complicating it.

| | `.sg_vault/publish/index.html` | the vault's own root `index.html` |
|---|---|---|
| what it is | **the loader**, generated by sgit | **ordinary vault content** — it *is* the site |
| state at rest | **plaintext by design**, safe in a public repo | **encrypted**, like every other file in the vault |
| exists? | always | usually not |
| its job | tell the browser the publish mode and where the read key is | be the page |
| reaches the served root | copied verbatim at deployment | **only if the deployer holds the key and expands plaintext** |

**So `sgit publish` never has to choose.** It emits ciphertext plus a generated plaintext
surface; vault content is still encrypted at that moment, so the second candidate does not
exist yet. `.sg_vault/publish/` contains **no vault content at all**, which is why it is safe
to commit to a public repository even for a private vault.

### The decision happens at deployment, because that is where both files exist

```
.sg_vault/publish/                        target-agnostic, produced by `sgit publish`
├── index.html                            the loader
├── manifest.json · cover.json
└── api/vault/read/<vault_id>/bare/…      ciphertext

          ↓  deploy — mode chosen by the target, key held only if expanding

<served root>/
├── api/vault/read/<vault_id>/bare/…      ciphertext (always)
├── index.html                            loader — unless the vault had one and it was decrypted
└── …decrypted vault files                only in the expanded mode
```

**Precedence: the decrypted vault `index.html` wins.** If someone has written a page for their
vault and is deploying it decrypted, that page is the site; the loader's job — negotiating a
key to read ciphertext — is already done for every file that was expanded.

This is the one place where the earlier framing was wrong: the merge belongs to the
**deployment step**, not to `sgit publish`, precisely because it is decryption that creates the
second candidate. That matches the maintainer's original instruction that the publishing target
code makes these decisions.

### The loader is simply replaced — no second copy

The two deployment modes are unambiguous on their own, so nothing needs preserving:

| Mode | Root `index.html` | Is the loader wanted? |
|---|---|---|
| ciphertext only | the loader | yes — it is the only way in |
| fully expanded | the vault's own page | **no** — every file is already plaintext; there is nothing left to negotiate a key for |

An earlier draft kept the loader at a secondary path (`vault.html`) to cover a *partial*
expansion — some files decrypted, ciphertext still present, readers needing a key for the
rest. **That mode does not exist:** expansion (`sgit vault expand`, P8) expands the vault,
not a subset, and there is no per-file visibility. The file was protecting against an
invented failure, so it is gone, and a fully-expanded deployment is just a static site with
no sgit artefacts in it.

`manifest.json` still records which file ended up at the root and its `sha256`, so the choice
is auditable from the artefact rather than from console history.

**If partial expansion is ever introduced**, this question comes back and must be answered then
— a subset-expanded site *does* need a reachable loader. Decide it with that feature, not in
advance.

### What this restores

- **Invariant I4 goes back to its unconditional form.** `.sg_vault/publish/index.html` is
  always sgit's bundled template, byte-identical across every vault, cacheable and pinnable —
  because nothing in the vault can change what `publish` emits.
- **The plaintext-surface allow-list is not weakened.** No vault content is emitted by
  `publish`, so nobody with vault write access can widen the surface by naming a file. Vault
  plaintext appears only through a deliberate expansion by a deployer who already holds the key.
- **Nothing is copied into a vault**, so no vault carries publishing scaffolding, and the
  brief's third principle — the vault does not know it is published — holds literally.

## 4. The folder ignores itself

`.sg_vault/publish/` contains a `.gitignore` whose entire content is `*`. It is written
by publish, so it is **declared in `manifest.json`'s plaintext surface** like every other
generated file — the audit lists everything, or it is not an audit.

This matters because of the working pattern the brief describes: a git repo on top of a vault,
committing `.sg_vault/bare/` (all ciphertext) and gitignoring only `.sg_vault/local/` (which
holds the key). In that arrangement `.sg_vault/publish/` is inside git's view, so without this
the output — a second copy of every object, plus up to 1.53 MB of Swagger UI — lands in git
history.

A nested `.gitignore` is the right instrument precisely because it keeps rule 2 true: **the
only file publish writes outside the folder is none.** Editing the user's root `.gitignore`
would break that.

## 5. What the deployer owns

sgit produces the folder and documents the conventions. A publishing target honours as many as
it wants — the artefact works either way.

| Decision | Owner | Note |
|---|---|---|
| where the folder lands (repo subdir, bucket prefix, docroot) | deployer | the layout is relative throughout; `api/openapi.json` uses `servers: ["."]` for this reason |
| `CNAME`, `.nojekyll`, `_headers`, `netlify.toml`, bucket policy | deployer | target-specific by definition |
| cache-control (long for `bare/data`, short for `bare/refs`) | deployer | immutable vs mutable is documented in `01` §3; only the host can act on it |
| CORS headers | deployer | GitHub Pages already sends `access-control-allow-origin: *` (measured) |
| **`.nojekyll`** on branch-root Pages deploys | deployer | Jekyll silently excludes dot-directories — without it, `.sg_vault/**` is not served and every object 404s while the page loads fine (`10` step 5). Actions-artifact deploys don't run Jekyll |
| redeploy propagation | deployer | Pages caches everything `max-age=600`: readers can get the old head ref for ~10 min after a redeploy; immutable objects are unaffected |
| **plaintext expansion** — decrypting vault content into the served root | deployer | needs the key, so it can only be a deployment-time choice; the key must never land in the output unless visibility is `public` |
| swapping the bundled loader for a hosted app on a CDN origin | deployer | fine as an **explicit** choice; the folder must stay self-sufficient without it, or the "no server needed" claim stops being true — and a hosted origin sees every reader (`09`) |

## 6. Acceptance criteria (P2)

- [ ] `sgit publish` takes **no output-directory argument**.
- [ ] After a publish, **the only path that changed is `.sg_vault/publish/`** — assert with a
      full work-tree hash before and after, not by inspection of the code.
- [ ] `sgit push` immediately after a publish adds **zero** objects and leaves the head
      unchanged (invariant I6).
- [ ] `.sg_vault/publish/.gitignore` exists and contains `*`; the user's root `.gitignore` is
      **byte-unchanged**.
- [ ] `.sg_vault/publish/index.html` is **always** the bundled template — publish a vault that
      contains its own root `index.html` and assert the emitted loader is byte-identical to the
      template, and that **no vault content** appears anywhere in the output.
- [ ] Expansion (deployment-time, key held): a vault `index.html` lands at the served root and
      replaces the loader; `manifest.json` records which file is at the root, with its `sha256`.
      No second copy of the loader is written.
- [ ] Expansion never writes a key file unless visibility is `public`.
- [ ] No target-specific file appears in the output — assert the emitted set against the
      allow-list, so a future `CNAME` needs a decision rather than a commit.
- [ ] Publishing twice produces byte-identical output (with `08`'s `generated_by` caveat).

## 7. Knock-on

- **`sgit vault serve`** takes no argument either in the common case: it serves
  `.sg_vault/publish/`, publishing first if the folder is absent or stale.
- **Deployment is out of the CLI's scope for now.** `rsync`, `aws s3 sync`, a GitHub Action, or
  a static host pointed at the folder. If a `sgit publish --copy-to <dir>` is ever wanted it is
  a *pure copy* of a finished folder, and can carry a one-line guard (refuse a destination
  inside the work tree) rather than the ruleset this file used to contain.
