# Publishing on approval

> How an approval becomes a live page — the Deploy Hook or GitHub dispatch that starts a build, coalescing, the hourly retry, and the Markdown trap.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/submissions/publishing/

---

Approving a listing writes a row; the page exists only after the site rebuilds. `providers.rebuild`
names what asks for that build — a Cloudflare Deploy Hook, a `repository_dispatch` to your own
GitHub Actions, or nothing — and `providers.rebuildAuto` decides whether an approval asks by itself.

> **Free edition**
>
> The free edition has no submissions, no `/admin/` and no `src/server/rebuild/`. Publishing is
> `pnpm build && pnpm exec wrangler deploy`, or a push.

## Setup

Pick the route that matches [Deploy on git push](https://zerodirs.com/docs/deploy/git-push/).

**Deploy Hook**

1. Create the hook: Cloudflare → Workers & Pages → your Worker → **Settings → Build → Deploy
   hooks → Create deploy hook**. The repository must be connected to Workers Builds first.

2. Store the URL. It takes no header and no body, so it is the whole credential.

   ```sh title="Terminal"
   pnpm cf:secrets DEPLOY_HOOK_URL
   ```

   The prompt ends with `✔ <n> characters received — setting it on <worker>… done`.

3. Switch the provider on and deploy.

   ```ts title="site.config.ts"
   providers: { rebuild: 'deploy-hook', rebuildAuto: true },
   ```

   `pnpm check:config`, then push. The provider is read from the deployed build at request time.

**GitHub dispatch**

The route when your own CI should run tests before every deploy, or the build minutes belong
on GitHub's account.

1. Turn the shipped workflow on. `deploy.yml` listens for `repository_dispatch` of type
   `rebuild` and stays skipped until this variable exists; without it, approving publishes
   nothing. [Deploy on git push](https://zerodirs.com/docs/deploy/git-push/) lists its secrets.

   ```sh title="Terminal"
   gh variable set USE_GITHUB_DEPLOY --body true
   ```

2. Store a token and the repository: a fine-grained personal access token with
   **Contents: Read and write** on the repository that builds the site (it begins
   `github_pat_`), and the repository as `owner/repo`.

   ```sh title="Terminal"
   pnpm cf:secrets GITHUB_TOKEN GITHUB_REPO
   ```

3. Switch the provider on and deploy.

   ```ts title="site.config.ts"
   providers: { rebuild: 'github-dispatch', rebuildAuto: true },
   ```

   GitHub fires `repository_dispatch` only on the default branch, which the workflow deploys
   from anyway.

**None**

The default. Every request is recorded as `skipped`, the dashboard bar counts what is not
published, and you publish when you choose:

```sh title="Terminal"
pnpm deploy
```

`/admin/` carries a warning for as long as this is the setting.

> **The hook fires, but the build reads Markdown**
>
> A build publishes an approval only when it reads the database. With `LISTINGS_SOURCE` unset on
> the builder, the loader falls back to `src/content/listings/` with a warning, the build goes
> green, and the new page stays a 404. Check the build log for
> `[zerodirs-d1] D1: <n> approved listings (featured <f>, 30-day clicks for <c>, <r> REST requests)`.
> `[zerodirs-files] listings: <n> from ./src/content/listings (…)` means the variables in
> [Listings from D1](https://zerodirs.com/docs/deploy/listings-from-d1/) are missing. With
> `LISTINGS_SOURCE=d1` and a credential missing the build fails naming it — the safer failure.

## Verify

Approve one listing in `/admin/` and follow it.

| What to check | Where | You should see |
| --- | --- | --- |
| The request | the publishing bar on `/admin/` | `Last request: requested via deploy-hook on <date> — build_uuid <id>`, or `repository_dispatch "rebuild" accepted by <owner/repo>` |
| The row | `pnpm exec wrangler d1 execute DB --remote --command "SELECT provider, status, detail FROM rebuild_requests ORDER BY created_at DESC LIMIT 1"` | `requested`, or `coalesced` when a build was already in flight |
| The build | Workers & Pages → your Worker → Builds, or the Actions run | a build shortly after the approval, with the `[zerodirs-d1]` line in its log |
| The page | `curl -sI https://<your domain>/<listingBase>/<slug>/ \| head -1` | `HTTP/2 200` once the build finishes |

Until then the submitter's status page says "goes live with the next publish", and the approval
mail "within a few minutes".

## How it works

**What an approval triggers.** Approving assigns the slug, stamps `approved_at`, writes the
audit row, queues the approval mail and calls `requestRebuild()` with reason `approve:<id>`.
Editing, featuring or unfeaturing an approved listing asks too, as do the hourly
`expireFeatured` (`featured-expired`) and the button (`manual`). Rejecting never does: nothing
on the live site changed.

**The request row.** Every request writes one `rebuild_requests` row — reason, provider,
status, attempts, detail — so the table is a complete log, not a sample. The status is
`requested` (the detail is the build id), `coalesced`, `failed` (the HTTP status or error text,
the hook URL scrubbed to `[DEPLOY_HOOK_URL]`), or `skipped` under `none`. A provider gets 10
seconds and never throws: an approve cannot fail because a build could not start.

**Coalescing.** A request within 60 seconds of a `requested` one is written as `coalesced`,
`folded into rebuild <id>`, and the provider is not called: ten approvals in a minute are one
build. Only `requested` rows open the window, so under `none` nothing coalesces and the
skipped rows stay an honest backlog. The hook itself answers `already_exists` with the id of a
build still queued; that is recorded as `requested`.

**The hourly retry.** `retryRebuilds` takes the oldest `failed` row with fewer than five
attempts, fires the provider once, and on success folds the other failed rows into that build.
A `429` or `5xx`, a timeout or a transport error is retryable; a `404` (the hook is gone), any
other `4xx`, or a GitHub `401`, `403`, `404` or `422` is not — but a row is rescanned until its
attempts run out either way. After five, only **Publish now** or `pnpm deploy` moves it.

**Manual mode.** `rebuildAuto: false` keeps the provider and makes every request wait:
approvals accumulate and **Publish now** fires one build. The button ignores `rebuildAuto`.

**The bar.** `RebuildBar` on `/admin/` shows the newest row and the count of approvals newer
than the last `requested` row: "Up to date", "N approved listings are waiting for the next
build", "The last publish request failed", or under `none` "N approved listings are not
published yet" with **Record a publish** — and since `none` never writes a `requested` row,
that count does not reset. Beneath: `Last request: <status> via <provider> on <date> — <detail>`.

## When it is off or degraded

| State | Visitor sees | `/admin/` reports | Fix |
| --- | --- | --- | --- |
| `rebuild: 'none'` | approved listings stay off the site | warning; rows `skipped` | `pnpm deploy`, or a provider |
| `'deploy-hook'` without `DEPLOY_HOOK_URL` | the same: the trigger degrades to `none` | error: "Approvals will not trigger a build." | `pnpm cf:secrets DEPLOY_HOOK_URL` |
| `'github-dispatch'` missing a secret | the same | error: the trigger falls back to `none` | `pnpm cf:secrets GITHUB_TOKEN GITHUB_REPO` |
| `USE_GITHUB_DEPLOY` unset | the dispatch is accepted, the run is skipped | `requested`, no build | `gh variable set USE_GITHUB_DEPLOY --body true` |
| The hook was deleted | nothing builds | `failed — HTTP 404: the deploy hook no longer exists` | create a new hook, store it |
| `LISTINGS_SOURCE` unset on the builder | the build runs, the page is a 404 | `requested`, build green | set the D1 variables |

## Configuration

| Field | Default | What it changes |
| --- | --- | --- |
| `providers.rebuild` | `'none'` | `'deploy-hook'` POSTs `DEPLOY_HOOK_URL`; `'github-dispatch'` posts to `GITHUB_REPO` with `GITHUB_TOKEN`; `'none'` records and skips |
| `providers.rebuildAuto` | `true` | whether an approval, an edit, a feature change or an expiry asks by itself |

See [`providers`](https://zerodirs.com/docs/configure/reference/#providers).

## Files

- src/
  - server/
    - rebuild/
      - index.ts `requestRebuild()`, the window, the row
      - deploy-hook.ts
      - github-dispatch.ts
      - none.ts
    - jobs.ts `retryRebuilds`
    - queries.ts what approve, update and feature ask for
  - components/admin/RebuildBar.astro
  - loaders/index.ts how `LISTINGS_SOURCE` is resolved
- .github/workflows/deploy.yml the `rebuild` listener
- .dev.vars.example the dashboard path, the token instructions
- .env.example

## Related

- [Deploy on git push](https://zerodirs.com/docs/deploy/git-push/) — The builder the trigger talks to.
- [Listings from D1](https://zerodirs.com/docs/deploy/listings-from-d1/) — The build variables that make a build read approvals.
- [Admin](https://zerodirs.com/docs/submissions/admin/) — The dashboard the bar sits on, and the hourly jobs.
- [Environment and secrets](https://zerodirs.com/docs/deploy/environment/) — DEPLOY_HOOK_URL, GITHUB_TOKEN and GITHUB_REPO.
