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:
-
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.
-
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:
- Docusaurus ships two frontmatter fields that solve different problems.
draft: truepulls a page out of production builds entirely; it only shows up when running the local dev server.unlisted: truekeeps 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.- Fumadocs takes a slightly different approach. A page only shows up in the sidebar if it's listed in the relevant
meta.jsonfile'spagesarray, 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 whatmeta.jsonsays 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"]
}- Mintlify uses a
hidden: truefrontmatter field. A hidden page is pulled from the sidebar, search, and the sitemap automatically, since Mintlify appliesnoindexto hidden pages by default, but it still resolves at its URL for anyone holding the link.
---
title: "Register Upkeep"
hidden: true
---- Astro has no draft system of its own. Teams running Starlight get a
draft: truefield 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
---- 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.comYou 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.pubRunning 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 masterRun 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 masterAdd 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 masterCommit and push it to staging:
git add .github/workflows/promote.yml
git commit -m "add promote workflow"
git push staging masterCreate 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/masterAll 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-featureOpen 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/masterThis 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/masterAn 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-featurePress 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-leaseMerge 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
.htpasswdfile. When real single sign-on is the better fit than a shared password,oauth2-proxysits 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: trueat 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:
- 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.
- 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
| Method | Privacy | Setup effort | Cost | Best fit |
|---|---|---|---|---|
| Frontmatter hiding (Docusaurus, Mintlify, Fumadocs) | Only if the source repo is private too | Low | Free | Tidying the nav, hiding from search, beta content you don't mind existing |
| Disposable drafts (Notion, HackMD, Google Docs) | High until promoted | Low | Free to cheap | Early review only, never the final destination |
| Private staging repo, synced and promoted automatically | High | Medium | Free | Full privacy needs, regular shipping cadence |
| Gated preview deployment (Cloudflare Access, Netlify, reverse proxy) | High | Medium | Free to low | Non-engineers need a real, browsable preview |
| Per-request access control (Fumadocs) | High, only if content also lives outside the public repo | Medium, plus a live server | Free | Teams already running Next.js as a live server (not static export) |
| Managed platform auth (Mintlify, ReadMe, GitBook) | Medium to high, by plan tier | Very low | Paid for real auth | Little 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.