Skip to main content
For the complete documentation index, see llms.txt. A full-text snapshot is also available at llms-full.txt.
← All posts

Documenting unreleased features

June 19, 2026

I had four days to document Upkeep registration, a new Chainlink product that let a contract register itself for automated execution, complete with its own funding flow and a minimum balance rule that would silently stop the Upkeep from running if I got it wrong.

None of it had shipped yet. To write the whole process end to end I had to register a real Upkeep myself and confirm the steps actually worked. After that, I just had to get the page through review and keep all of it out of sight until the date marketing had fixed for the announcement.

I didn't start with a Notion page or a HackMD doc, though I know plenty of writers reach for one first. Four days doesn't leave room for a draft that has to be rebuilt into a real page later, so I went straight to a private fork of the docs repo: a second remote that only reviewers could reach, with its own preview so people could actually read the page instead of a raw markdown diff. Once it cleared review, I merged the branch into the public docs myself a few hours before marketing's announcement went out.

I've run that same shape of release more than once since: test the feature myself, write it up somewhere private, get it reviewed, merge by hand close to the date. It's worked every time, but I'm sure some of what I did by hand could be scripted instead, which is most of what this post is actually about.

These days, almost every web3 docs site runs on one of a handful of platforms:

  • Docusaurus (Sui, Arbitrum)
  • Fumadocs (Ethereum, Solana, Qvac, Avalanche, Uniswap)
  • Mintlify (Near, Jupiter, Base)
  • Astro (Chainlink)
  • GitBook (Hyperliquid, Mantle)

That's what this post sticks to, instead of covering every static site generator that exists, including the Eleventy setup my own team was running back when I documented Upkeep.

None of the approach for Upkeep was really a tooling question. It came down to one planning decision, made before I wrote a word: how private "not yet public" actually needed to be. That single answer decided almost everything else, which method to use and how much automation was worth building around it.

How private does "private" actually need to be?

Before reaching for any tool, it helps to decide what "hidden" actually means for the page you're writing. Generally, teams land in one of two camps, and a confirmed announcement date usually makes it obvious early which one you're in:

  1. The first is semi-private. You're fine with the content existing somewhere a determined person could find it: an old commit in a public repo, a page nothing links to. The point is just that casual visitors and search engines won't stumble onto it by accident. This is closer to tidiness than privacy, keeping a half-finished page out of the nav, or sharing a direct link with one beta tester without telling everyone else.

  2. The second is fully private. The page must not be reachable by anyone outside an approved list, full stop. This is what fixed-date launches and design-partner programs actually require, even when teams don't always realize that's what they're asking for. A confirmed date with marketing already lined up is about as clear a signal as you'll get that you're in the second camp. Upkeep was there from day one.

Most documentation tooling defaults to making semi-private easy and fully private hard, so teams often end up with the wrong guarantee for what they're actually protecting. That mismatch is where most of the pain in this space comes from: the private repo that "doesn't scale," the unlisted page that turns up in Google anyway, the Notion link nobody can find six months later. Fully private is the harder problem, and it's the one that mattered to me, so that's where most of this post ends up living.

One quick gut check before picking a method: who exactly has to be kept out, the general public or literally everyone but a handful of named partners, and how expensive a leak would be relative to how long the secret has to hold. Three days until a synchronized launch is a different problem than a six-month confidential project. Both answers decide which method fits. Tooling impressiveness has nothing to do with it.

Approach #1: Semi-private

Disposable drafts

Most teams start somewhere messier than any pipeline: a Notion page, a HackMD doc, a shared Google Doc. That's fine for the early review phase, especially when there isn't a tight deadline forcing a jump straight to something sturdier. These tools make terrible permanent homes, though, especially in the Docs as Code and markdown-first infrastructure.

The page collects a few rounds of comments, the shipped feature changes slightly from what was written, and two slightly different descriptions of the same thing end up living in two different systems, with nobody remembering to check the older one.

The fix tends to be procedural more than technical. The drafts that survive that transition cleanly are usually the ones that already mirror the heading structure and frontmatter fields of the real docs, so turning one into an actual page later looks more like a copy and paste than a rewrite.

I've gotten in the habit of treating the link itself as a comment thread with markdown rendering, something people read and react to, rather than a page anyone is meant to bookmark as the real destination.

Frontmatter gating

This whole approach boils down to a flag somewhere in a page's metadata that tells the build process what to do with it. It's one file with one history, so there's no second copy of the content sitting anywhere else. It's the option most teams reach for first because it needs no second repo and no new infrastructure.

This hides content from the rendered site. Git history is untouched by it. If the docs repo is public, anyone willing to read commits can find the page regardless of what the build does with it.

That's a fine tradeoff if the goal is keeping a half-finished page off the nav and out of search results, or if the source repo happens to be private even though the rendered site is open. For real privacy in a fully public repo, an actual access control does the work. A frontmatter flag is not enough.

That said, you can play with frontmatter in a few ways:

  1. Docusaurus ships two frontmatter fields that solve different problems. draft: true pulls a page out of production builds entirely; it only shows up when running the local dev server. unlisted: true keeps the page in the production build and gives it a working URL, but drops it from the sidebar and the search index. That's the one to reach for once a page is finished and the only goal is handing a beta tester a direct link before announcing it widely.
---
title: Register Upkeep
unlisted: true
---

Register an Upkeep, fund it, and Chainlink Keepers runs it automatically.
  1. Fumadocs takes a slightly different approach. A page only shows up in the sidebar if it's listed in the relevant meta.json file's pages array, so leaving it out keeps it out of navigation. The page still gets a working route, though, since Fumadocs generates routes from the files in the content directory regardless of what meta.json says about navigation. For real filtering rather than just hiding from the sidebar, Fumadocs's own documentation recommends going a level deeper: tag pages with a custom field and filter the content source before it ever reaches the page loader, so excluded files never exist as far as that build is concerned.
// content/docs/upkeep/meta.json
{
  "title": "Upkeep",
  "pages": ["index", "funding"]
}
  1. Mintlify uses a hidden: true frontmatter field. A hidden page is pulled from the sidebar, search, and the sitemap automatically, since Mintlify applies noindex to hidden pages by default, but it still resolves at its URL for anyone holding the link.
---
title: "Register Upkeep"
hidden: true
---
  1. Astro has no draft system of its own. Teams running Starlight get a draft: true field that excludes the page from production builds, the same idea as Docusaurus's. The flag only takes effect on a real production build; the dev server ignores it.
---
title: "Register Upkeep"
draft: true
---
  1. Others. Most static generators have nothing like this built in at all. They typically read files out of a directory, so the same idea can be built once, as a pre-build step that splits the content folder into two folders by a frontmatter field, then point whichever tool is in use at one or the other.
#!/usr/bin/env node
// scripts/split-content.mjs
// Splits content/ into dist/content-public and dist/content-internal
// based on a `visibility` frontmatter field.

import { readdirSync, statSync, mkdirSync, copyFileSync, readFileSync } from "node:fs";
import { join, relative, dirname } from "node:path";
import matter from "gray-matter";

const SRC = "content";
const TARGETS = {
  public: "dist/content-public",
  internal: "dist/content-internal",
};

function walk(dir) {
  for (const entry of readdirSync(dir)) {
    const full = join(dir, entry);
    if (statSync(full).isDirectory()) {
      walk(full);
      continue;
    }
    if (!full.endsWith(".md") && !full.endsWith(".mdx")) continue;

    const { data } = matter(readFileSync(full, "utf8"));
    const visibility = data.visibility ?? "public";

    for (const [target, outDir] of Object.entries(TARGETS)) {
      if (target === "public" && visibility !== "public") continue;
      const dest = join(outDir, relative(SRC, full));
      mkdirSync(dirname(dest), { recursive: true });
      copyFileSync(full, dest);
    }
  }
}

walk(SRC);

A page just needs visibility: beta in its frontmatter to drop out of the public tree while still building normally into the internal one.

Approach #2: Fully private

Everything above hides content from a render. This section is about content that truly cannot be reached by anyone who shouldn't see it, which is what Upkeep actually needed at that time. It's also where automating the boring parts pays off most, since the entire point is removing the manual steps that turn into leaks or missed deadlines.

One-way sync private fork

A private repo with tightly restricted permissions is the one method here that gives a guarantee instead of an honor system. If someone doesn't have access to the repo, they cannot read the page, and there's no URL to guess or cached copy to worry about.

nginx runs a public version of exactly this. Their public docs repo is where writing happens by default, and it syncs one way into a separate internal repo used only for content that can't be public yet. Two remotes on the same local checkout connect them: origin for the public repo, and a second remote pointing at the private one.

The setup has two distinct directions of sync, and mixing them up is the most common reason private-fork setups fall apart. Public content needs to flow into the private repo continuously, since writers there need current material to draft against. Finished pages travel the other way only once, when they are ready to ship. Someone wires up the first direction, forgets the second exists, and ends up resolving merge conflicts by hand months later. Both directions are worth scripting before you need them.

The steps below cover the full setup: connecting the two repos and adding the automation that runs the daily writing loop from branching through promotion.

Create the private repo on GitHub

Go to GitHub, create a new repository, name it something like docs-staging, and set visibility to Private. No GitHub Pages setup is needed. It is a place for branches and pull requests that only invited collaborators can see.

Verify your SSH key

Before adding a second remote, confirm your local machine can authenticate with GitHub:

ssh -T git@github.com

You should see Hi yourname! You've successfully authenticated. If you get Permission denied (publickey) instead, generate and add a key:

cd ~/.ssh
ssh-keygen -t ed25519 -C "your@email.com"
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
cat ~/.ssh/id_ed25519.pub

Running ssh-keygen from ~/.ssh writes the key files to that directory. Copy the output of cat, go to GitHub Settings → SSH and GPG keys → New SSH key, paste it, and save. Run ssh -T git@github.com again to confirm authentication works before continuing.

Add the staging remote and seed it

From your existing local clone of the public docs repo, add the private repo as a second remote and push the current public history into it:

git remote add staging git@github.com:yourname/my-docs-staging.git
git push staging master

Run git remote -v to confirm both remotes are registered: origin pointing at the public repo and staging pointing at the private one.

Add the sync workflow

Still on master, create .github/workflows/sync-from-public.yml. This workflow pulls the latest public content into staging/master every morning and can also be triggered manually:

# .github/workflows/sync-from-public.yml
name: Sync from public site
on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch: {}

jobs:
  sync:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Pull in the latest public content
        run: |
          git config --global user.email "YOUR_GITHUB_USERNAME@users.noreply.github.com"
          git config --global user.name "YOUR_GITHUB_USERNAME"
          git remote add public https://github.com/YOUR_GITHUB_USERNAME/YOUR_PUBLIC_REPO.git
          git fetch public master
          git checkout master
          git merge public/master --no-edit -X ours
          git push https://x-access-token:${{ secrets.PUBLIC_REPO_PUSH_TOKEN }}@github.com/YOUR_GITHUB_USERNAME/YOUR_STAGING_REPO.git master

Add the promote workflow and its token

The promote workflow copies a finished page from the private repo into the public one and opens a pull request. It requires write access to the public repo, so create a fine-grained personal access token first.

Go to GitHub Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token. Set the resource owner to your account, restrict access to the public docs repo only, grant read and write on Contents and Pull requests, and generate your token.

Copy the token and save it as a secret named PUBLIC_REPO_PUSH_TOKEN on the private repo under Settings → Secrets and variables → Actions.

Still on master, create .github/workflows/promote.yml:

name: Promote page to public site
on:
  workflow_dispatch:
    inputs:
      commit_message:
        description: "Commit message for the PR (e.g. 'docs: add registration guide')"
        required: true

jobs:
  promote:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout staging
        uses: actions/checkout@v4
        with:
          path: staging
          fetch-depth: 2

      - name: Checkout public site
        uses: actions/checkout@v4
        with:
          repository: YOUR_GITHUB_USERNAME/YOUR_PUBLIC_REPO
          token: ${{ secrets.PUBLIC_REPO_PUSH_TOKEN }}
          path: public

      - name: Copy changed files across
        run: |
          cd staging
          git diff --name-only HEAD~1 HEAD -- content/ | while read path; do
            if [ -f "$path" ]; then
              mkdir -p "../public/$(dirname "$path")"
              cp "$path" "../public/$path"
              echo "Copied: $path"
            else
              echo "Deleted (skipping): $path"
            fi
          done

      - name: Open a PR against the public repo
        working-directory: public
        env:
          GH_TOKEN: ${{ secrets.PUBLIC_REPO_PUSH_TOKEN }}
        run: |
          git config user.email "YOUR_GITHUB_USERNAME@users.noreply.github.com"
          git config user.name "YOUR_GITHUB_USERNAME"
          BRANCH="promote/$(date +%Y%m%d-%H%M%S)"
          git checkout -b "$BRANCH"
          git add content/
          git commit -m "${{ inputs.commit_message }}"
          git push origin "$BRANCH"
          gh pr create --title "${{ inputs.commit_message }}" \
            --body "$(git diff --name-only HEAD~1 HEAD -- content/)" \
            --base master

Commit and push it to staging:

git add .github/workflows/promote.yml
git commit -m "add promote workflow"
git push staging master

Create your feature branch

With the workflows in place, branch off staging/master using a prefix that keeps private branches visually distinct from public ones:

git fetch staging
git checkout -b staging/new-feature staging/master

All commits, pull requests, and preview deploys stay on this branch against the staging remote. Nothing is pushed to origin until the page is ready to ship.

Write, commit, and push

Make your edits, then push to the private repo:

git add .
git commit -m "draft new-feature page"
git push staging staging/new-feature

Open a pull request on staging

Open a pull request against staging/master on the private repo. This is where reviewers leave comments and request changes before the page ships. Because the private repo is only accessible to invited collaborators, the full review can happen there without any of the content being visible publicly.

Once all reviewers have approved, close the pull request without merging. The page does not land on staging/master directly. Promotion to the public repo is what ships it.

Sync with origin before promoting

Before squashing and promoting, fetch any changes that merged into the public repo while you were writing:

git fetch origin
git rebase origin/master

This ensures the promoted commit applies cleanly to the current public history and avoids conflicts in the pull request.

Squash your commits

Squash all commits on the branch into one before the page ships publicly. A staging branch carries its full commit history when promoted, including any WIP messages or internal notes:

git rebase -i origin/master

An editor opens listing your commits. Press i to enter insert mode. Drop the workflow commits and squash the content ones:

d f930d95 # add sync workflow
d 5e3f2d5 # add promote workflow
pick 29a2c46 # add new-feature
s 9b8175b # update new-feature

Press Esc, then type :wq and hit Enter. A second editor opens with both commit messages. Delete everything and write a single clean message, then Esc, :wq, Enter again. If the branch has only one content commit, use pick instead of s and skip the second editor.

Next, push the updated branch:

git push staging staging/new-feature --force-with-lease

Merge the staging pull request

Once reviewers have approved, merge the pull request on the staging repo.

Run the promote workflow

Go to the private repo on GitHub, open Actions, select Promote page to public docs, and click Run workflow. Enter a commit message describing the change. The workflow automatically detects every file touched in the latest staging commit, including images and other assets, and copies them across.

The workflow copies the file into the public repo and opens a pull request there. Merging that pull request is what ships the page and triggers the public site's deploy workflow.

Before using this on a real launch, run it once with a throwaway file to verify the token scope and paths are correct, then delete the test file from both repos.

Deployment gating

People other than your own writers usually need to read the draft before launch: support, sales, a handful of design partners. A raw markdown file in a private GitHub repo is a rough reading experience for them, so this is where deployment gating comes in.

Deployment gating means standing up a complete second site straight from the private repo's branch and putting something real between it and casual visitors, while production keeps deploying publicly as normal. This is roughly what I built for Upkeep, a second deployment of the docs site pointed at the private branch that reviewers could actually click through instead of reading a raw diff.

  • Cloudflare Pages can do this for close to free. Every branch already gets its own preview URL, and Cloudflare Access can sit in front of just that one subdomain instead of production, free for up to 50 users, with proper login and nothing extra to host or run. I haven't seen this on a web3 docs site. Worth knowing it exists.

  • Netlify covers similar ground, though the path has shifted recently. Password protection used to be available on every account, letting you set a shared password or require visitors to log into your Netlify team. Now that feature lives on the Pro plan and above. On the free tier, Netlify's own docs include a template for a small Edge Function that checks a password against an environment variable, which works without a paid plan since Edge Functions are part of the free tier too.

  • If you'd rather not depend on a specific platform's feature, the generic version is just a password check sitting in front of whatever serves the files. With nginx, that's a single .htpasswd file. When real single sign-on is the better fit than a shared password, oauth2-proxy sits in front of the same server and checks against whatever identity provider the team already uses.

  • Mintlify already builds a version of this straight into the platform. Switching on Partial Authentication in the dashboard puts every page behind a login by default, except whichever ones are explicitly marked public: true at the page or group level. A Pro plan gets a single shared password; Enterprise adds OAuth, JWT, and login restricted to people inside the same Mintlify organization. It's the same idea as a gated preview deployment, just with the gate built into the hosting instead of bolted on next to it.

  • ReadMe and GitBook cover the same ground as Mintlify. The tradeoff is the same across all of them: a polished editor and access control with real review workflows, with almost no engineering effort, in exchange for a subscription and some lock-in to their format. When budget or a self-hosting requirement drives the choice, pairing a private staging repo with automated sync and a gated preview deployment gets most of the same practical outcome, for the price of a GitHub Action and a free Cloudflare account.

Per-request access control

Fumadocs runs on Next.js, which can render a page per request instead of only at build time. In theory that means skipping a second deployment entirely by filtering the content source on a permission field and returning a real 404 when the visitor doesn't have access.

In practice, two things narrow how often that actually applies:

  1. The first is that it only works while running Next.js as a live server, since the request is what gets checked. Most docs sites, including this one, deploy as static HTML exports, so there's nothing to check against at all.
  2. The second is the bigger problem: the 404 only hides the rendered page. The file behind it stays in the repo. If that repo is the same public docs repo as everything else, anyone who looks at the repository itself can read it no matter what the loader does at request time. The live site might return a clean 404, but the repository never does.

This is the same problem the frontmatter trick had earlier in this post. A 404 at request time doesn't help if the markdown file is still sitting in a public git history.

The per-request version only earns its keep if the content source is also kept somewhere that isn't the public repo: a private database, a headless CMS, a separate private repo fetched at request time. At that point it stops being a Fumadocs-specific shortcut and turns into the private-staging-repo idea from earlier, just with one deployment instead of two.

Summary

MethodPrivacySetup effortCostBest fit
Frontmatter hiding (Docusaurus, Mintlify, Fumadocs)Only if the source repo is private tooLowFreeTidying the nav, hiding from search, beta content you don't mind existing
Disposable drafts (Notion, HackMD, Google Docs)High until promotedLowFree to cheapEarly review only, never the final destination
Private staging repo, synced and promoted automaticallyHighMediumFreeFull privacy needs, regular shipping cadence
Gated preview deployment (Cloudflare Access, Netlify, reverse proxy)HighMediumFree to lowNon-engineers need a real, browsable preview
Per-request access control (Fumadocs)High, only if content also lives outside the public repoMedium, plus a live serverFreeTeams already running Next.js as a live server (not static export)
Managed platform auth (Mintlify, ReadMe, GitBook)Medium to high, by plan tierVery lowPaid for real authLittle appetite for building this yourself

No method wins outright, since the two questions from the start of this post have different answers for different teams. For Upkeep specifically, the whole stack was simpler than the automated version described above: a private fork with a real preview for reviewers, merged by hand into the public repo a few hours before marketing's announcement.

I've run that pattern repeatedly, and there's a decent chance the part I still do by hand, pulling fresh public content into the private fork and pushing finished pages back out, could be scripted the way nginx's own setup is. The part that's never been clean yet either way is the first sync: the very first time a private fork meets a public repo with months of separate history, the merge isn't automatic, and someone has to sit with it by hand once before any automation can take over.

Further reading