Publish a folder of Markdown from git

Embed
Preview of the card
<a href="https://forumulate.com/guides/publish-from-git"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/61cf5138-cbfd-4971-ad54-e415d01471a3/social-card.svg?v=3e7ae33eec8b" alt="Publish a folder of Markdown from git" width="600" height="315"></a>

forumulate publish <folder> makes a folder of Markdown into live pages. When you run it again, it changes only what changed, then reads the space back and checks that the change landed. It is part of @forumulate/cli from version 0.4.0.

Without it, every team that keeps its docs in a repository ends up writing the same publisher. That script maps front matter to pages, keeps re-runs idempotent, prunes removed pages, keeps the order, reads the result back, supports a dry run, and imports an OpenAPI reference. This command does all of those things.

The short version

npm install -g @forumulate/cli
forumulate login                                   # or FORUMULATE_TOKEN in CI
forumulate publish ./docs --org acme --space docs --dry-run   # the plan
forumulate publish ./docs --org acme --space docs             # do it

A dry run writes nothing. Against a public space it needs no token: it reads the published space anonymously and compares your folder against that.

forumulate sync <folder> is the same command the other way round: it prints the plan unless you pass --apply. It also covers FAQ questions and blog categories, and --diff shows what changes inside each piece. See Content as code.

How a folder becomes a space

For a docs space (and the other page-based types: legal, FAQ, careers, contact, courses, custom):

On disk

In the space

*.md at the top of the folder

pages in the space's default collection (or rootCollection)

a folder, 02-guides/

a collection called "Guides", second in the sidebar

a folder inside a collection, install/

a parent page, and the folder's files nest under it

that folder's index.md or README.md

the parent page itself (without one, a title-only page stands in)

_collection.yml in a collection folder

that collection's name, slug, order and nav

an image or PDF a page points at

uploaded once, and the reference is rewritten to the uploaded URL

a link to another .md file in the folder

rewritten to that page's address

For a blog, a changelog, a forum or a roadmap, every Markdown file in the folder is one post. A 2026-10-01-launch.md file name gives the post its date.

Some names are never content: README, LICENSE and the other repository files, anything starting with . or _ (such as _drafts/), and the manifest. In a posts folder a README is skipped wherever it is. In a docs folder it stays the folder's own page unless the space says readme: skip. The plan lists every file it skipped, and ignore: on a space adds globs of your own. The full list is in Content as code.

A number at the start of a name (01-, 02_) sets the order and is left out of the address. 01-install.md is published at install.

Front matter

---
title: Install the agent # else the first "# heading"
slug: install # else the file name, without its number
description: Two minutes, one command.
icon: download
tags: [setup, cli]
order: 2 # also: sidebar_position, nav_order, weight
sidebar_label: Install # the shorter name in the sidebar (navTitle)
hidden: true # out of the sidebar, still published
redirect_from: [/old-install] # addresses that now redirect here
draft: true # left out, and the plan says so (never pruned either)
---

Any field the page model takes can go in the front matter under its own name, for example seoTitle, noindex, layout, hero, badge, canonicalUrl, pageChrome or themePatch. snake_case works as well. The parameters a space type declares, such as audience and deprecated on docs, go in the same way. The common names other tools use are mapped: Docusaurus sidebar_label and sidebar_position, Jekyll nav_order and nav_exclude, Hugo weight and linkTitle. The plan lists any key it does not understand and leaves it unsent, so a misspelt key does not get dropped without a trace.

The body goes through unchanged, including our Markdown dialect (:::note, tabs, admonitions, MDX components). The same converter that powers the importer reads it.

To clear a field the command set earlier, set it to null. The command never removes a field just because a key is no longer in the file, because it cannot tell whether it set that value or someone set it in the studio.

Re-runs, prune and order

Pages are matched by slug. A changed page is updated in place, at the same address. A file whose words have not changed is not sent at all.

The comparison looks at the words. Wrapping, the title heading and link targets are ignored, so a change to formatting alone (for example, making a word bold) is not detected. Pass --force to re-send everything.

--prune removes what the folder no longer has: pages and posts with no file, and collections with no folder. Pages go to the trash, where you can restore them. Prune never deletes:

  • anything in a collection an openapi: import owns,

  • anything listed in the manifest's keep:,

  • a page that still has kept pages under it,

  • anything at all on a run narrowed with --only.

Order comes from order, then the number prefix, then the name. Only the pages that are out of place are moved. Where the API has a single call to order a whole collection, the command uses it. Otherwise it moves one page per request, with a gap between requests (--pace <ms>, 250 by default).

The manifest

forumulate.yaml (or .yml, or forumulate.json) at the top of the folder describes everything about the space that is not a page:

org: acme
site:
  slug: main
  config: { tagline: 'Docs, forum and blog in one place' }
spaces:
  - path: docs
    space: docs
    create: { type: DOCS, name: Docs } # make the space if it is missing
    theme: { primaryColor: '#0a7f5a' } # PATCH …/theme (deep merge)
    settings: { defaultVersionLabel: Latest }
    collections:
      02-guides: { name: 'Guides & recipes' }
    openapi:
      - spec: openapi.yaml # or url: https://…
        collection: API reference
        mode: per-tag
    keep: [changelog-archive]
  - path: blog
    space: blog
  - path: status
    space: status
    monitors:
      - { name: API, url: 'https://api.acme.com/health', intervalSeconds: 60 }

Each part is compared before it is written. A theme or site config that already holds every key you name is left alone. Monitors are matched by name. The OpenAPI reference is imported every run through POST …/docs/openapi/import, which converges on the spec. An unknown key in the manifest is an error, not something the command silently skips.

Narrowing a run

--only takes slugs, files, folders, a space's folder, or the name of a manifest part (site, theme, settings, openapi, monitors). You can combine them with commas:

forumulate publish . --only docs/01-start,theme

Read-back

After a real run, the command plans again against what is now live. If that plan still has anything to do, the change did not land. The command lists each such change and exits 1. Use --no-verify to skip this step, for example on a very large space.

--json prints the plan, the errors and the read-back result in a form a pipeline can read.

Or let pushes sync the space

The CLI publishes when your pipeline runs it. Git sync is the other way round: you connect the space to a repository once, and the Git host tells Forumulate about every push.

PUT /orgs/{orgId}/spaces/{spaceId}/github (space.update) connects a repository on GitHub, GitLab or Bitbucket: provider (github by default), repo, branch (main by default), path and an access token. Its answer, like GET /orgs/{orgId}/spaces/{spaceId}/github, carries webhookUrl. Add that URL as a webhook on the repository. It ends in a secret issued for this connection, and connecting again keeps the same secret, so a webhook you already added keeps working.

The host then calls POST /git/webhook/{secret}. POST /github/webhook/{secret} is the same receiver at its older address, kept for webhooks added before the rename. Once the delivery is verified (see below) it answers 202 straight away and does the work afterwards, because hosts expect a fast answer:

  • A push to the connected branch imports the repository into the space again, as POST /orgs/{orgId}/spaces/{spaceId}/github/import does. A push to any other branch is ignored, and so is every push to a changelog space.

  • A merged pull request from a branch Forumulate opened for a change request (POST /orgs/{orgId}/spaces/{spaceId}/github/change-requests/{crId}/pull-request) merges that change request, which is what publishes it.

  • A GitHub release event on a changelog's connection becomes that release in the changelog. On any other space it is dropped.

With contentSync: true on the connection (and contentSyncPrune for prune), a push runs the content-as-code sync instead of the import: the connected path and its forumulate.yml become posts, pages, FAQ questions, releases and blog categories, exactly as forumulate sync --apply would make them. POST /orgs/{orgId}/spaces/{spaceId}/github/sync runs it by hand (a plan unless {"dryRun": false}), and GET /orgs/{orgId}/spaces/{spaceId}/github/sync-runs lists what each run did. See Content as code.

The secret in the URL says which connection a delivery is for; it does not prove who sent it. That is the signing secret's job. Connecting a space for the first time answers it once, as signingSecret in the connection, and the console's Git sync panel shows it under Webhook secret. It is never in the URL, and GET never returns it. Enter it as the webhook's secret on GitHub or Bitbucket, which then sign each delivery with X-Hub-Signature-256 or X-Hub-Signature (an HMAC-SHA256 of the body), or as the secret token on GitLab, which sends it as X-Gitlab-Token. Every delivery, whatever the event, needs that proof: one without it, or with one that does not match, is answered 401 and nothing happens. An unknown secret is answered 401 the same way.

POST /orgs/{orgId}/spaces/{spaceId}/github/signing-secret (space.update) issues a new signing secret and answers it once; deliveries signed with the old one are refused from then on. Connections made before October 2026 have no signing secret yet. The connection's signing field says legacy, and their deliveries are still verified with the URL secret, which is how those hosts were set up. Anyone holding the URL can sign with that, so generate a signing secret and enter it on the host. Connecting again keeps whatever signing secret the connection has, so it never breaks a host that works.

The work runs as the member who connected the space. The receiver takes 60 requests a minute.

Answer questions from GitHub issues

Separately from publishing, you can connect a repository so people can ask your documentation questions in its issue and pull-request comments. A new comment containing /forumulate ask <question> or /forumulate search <query> is answered with a grounded reply over your public docs spaces; any other text after /forumulate is searched. ask needs an AI provider connected to the organization, and without one the reply says to use search. Comments by bots are ignored. Only published, publicly visible content is ever used. This needs org.manage_integrations.

POST /orgs/{orgId}/github-agent
{
  "repoFullName": "acme/product",
  "webhookSecret": "a-long-random-string",
  "accessToken": "<token that may comment on that repo>"
}

repoFullName must be owner/repo. webhookSecret (8 to 200 characters) is the secret you also enter on the GitHub webhook, which should point at /integrations/github-agent/webhook on the API and send issue-comment events; every delivery is verified against it. accessToken is optional: without it the agent can read the webhook but cannot post the reply, and the status says so. Connecting again replaces the previous settings, since an organization has one.

Call

What it does

GET /orgs/{orgId}/github-agent

Returns { github: { connected, repoFullName, canPostComments } }.

DELETE /orgs/{orgId}/github-agent

Disconnect.

In GitHub Actions

# .github/workflows/docs.yml
name: Publish docs
on:
  push:
    branches: [main]
    paths: ['docs/**', 'forumulate.yaml']
  pull_request:
    paths: ['docs/**', 'forumulate.yaml']

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      # On a pull request, show the plan and write nothing.
      - if: github.event_name == 'pull_request'
        run: npx -y @forumulate/cli@0.4 publish . --dry-run
        env:
          FORUMULATE_TOKEN: ${{ secrets.FORUMULATE_TOKEN }}
      # On main, publish and remove what was deleted.
      - if: github.event_name == 'push'
        run: npx -y @forumulate/cli@0.4 publish . --prune
        env:
          FORUMULATE_TOKEN: ${{ secrets.FORUMULATE_TOKEN }}

Create the token with read and write, limited to the one organization (see API tokens and scopes). Store it as the repository secret FORUMULATE_TOKEN.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.

Github

Operation

Request

What it does

Shortcuts

Github_status

GET /orgs/{orgId}/spaces/{spaceId}/github

Get the GitHub in a space

forumulate github status<br>client.integrations.github.status()

Github_connect

PUT /orgs/{orgId}/spaces/{spaceId}/github

Replace the GitHub in a space

forumulate github connect<br>client.integrations.github.connect()

Github_egressIps

GET /orgs/{orgId}/spaces/{spaceId}/github/egress-ips

List egress IPs in a space

forumulate github egress-ips<br>client.integrations.github.egressIps()

Github_rotateSigningSecret

POST /orgs/{orgId}/spaces/{spaceId}/github/signing-secret

Create the signing secret in a space

client.integrations.github.rotateSigningSecret()

Github_import

POST /orgs/{orgId}/spaces/{spaceId}/github/import

Import the GitHub

forumulate github import<br>client.integrations.github.import()

Github_runSync

POST /orgs/{orgId}/spaces/{spaceId}/github/sync

Run the content sync of a connected repository

forumulate github sync<br>client.integrations.github.runSync()

Github_syncRuns

GET /orgs/{orgId}/spaces/{spaceId}/github/sync-runs

List the content-sync runs in a space

forumulate github runs<br>client.integrations.github.syncRuns()

Github_syncRun

GET /orgs/{orgId}/spaces/{spaceId}/github/sync-runs/{runId}

Get a content-sync run, with its plan

forumulate github run<br>client.integrations.github.syncRun()

Github_export

POST /orgs/{orgId}/spaces/{spaceId}/github/export

Export the GitHub

forumulate github export<br>client.integrations.github.export()

Github_pullRequest

POST /orgs/{orgId}/spaces/{spaceId}/github/change-requests/{crId}/pull-request

Create the pull request in a space

forumulate github pull-request<br>client.integrations.github.pullRequest()

Github Agent Admin

Operation

Request

What it does

Shortcuts

GithubAgentAdmin_status

GET /orgs/{orgId}/github-agent

Get the GitHub agent in an organization

client.integrations.githubAgentAdmin.status()

GithubAgentAdmin_connect

POST /orgs/{orgId}/github-agent

Create the GitHub agent in an organization

client.integrations.githubAgentAdmin.connect()

GithubAgentAdmin_disconnect

DELETE /orgs/{orgId}/github-agent

Delete the GitHub agent in an organization

client.integrations.githubAgentAdmin.disconnect()

The Forumulate teamUpdated