Stand up a site on GitHub Pages
For a new static site in its own directory: one command creates the repo, publishes it, and gives back a URL that has been checked.
This is scoped to new sites. If the directory already has a git remote, stop and ask the user what they want — publishing an existing repo means deciding about its branches and history, which is a different conversation.
Prerequisites
git and gh installed, and gh auth status showing an authenticated account. If auth fails, ask the user to run gh auth login themselves — it's interactive and needs a browser. In Claude Code they can run it inline by typing ! gh auth login.
For the layout check (optional but worth having): the playwright Python package with its Chromium (pip install playwright && playwright install chromium), or a Chrome/Chromium/Edge/Brave install. Without either, the check skips itself and says so.
Writing the page
If you are authoring the HTML (not just publishing a file the user handed over), the page has to work on a phone and on a laptop. Every page this skill has shipped that looked wrong failed in one of two ways, and both are cheap to prevent:
- Sideways scrolling on a phone. The layout ends up wider than the screen, so text is cut off at the right edge and the reader has to pan. Nearly always a grid or flex column that was allowed to grow to fit a long line in a
<pre>, a table, or a URL. - A half-empty laptop screen. A reading column with
max-widthbut no centering, so it hugs the sidebar and leaves the right third of the window blank.
Rules that prevent them:
<meta name="viewport" content="width=device-width, initial-scale=1">in<head>. Without it phones lay the page out at 980px and shrink it.- Reading column: cap it and center it.
max-width: 78ch(roughly 720–860px at body sizes) withmargin-inline: auto. In a sidebar-plus-content grid, thatmargincenters<main>inside its column — that's the piece people forget. Amax-widthalone parks the column on the left. - No bare
1frorautofor a column that holds text. Bare1frmeansminmax(auto, 1fr), andautolets one long<pre>line or URL push the column past the viewport. Writeminmax(0, 1fr), and give flex childrenmin-width: 0. This applies inside the mobile breakpoint too — switching togrid-template-columns: 1frthere is exactly how the phone bug ships. - Anything that can be wider than the screen scrolls inside itself:
pre, table { overflow-x: auto; max-width: 100% }.img, video, svg { max-width: 100%; height: auto }.body { overflow-wrap: anywhere }so long URLs and tokens wrap instead of pushing. - One breakpoint at least (around 900px): a sidebar becomes a stacked block (
position: static; height: auto, not100vh), side padding drops to about 1.25rem, headings shrink.clamp()sizes handle most of this without media queries:h1 { font-size: clamp(1.8rem, 5vw, 2.6rem) }. - Body text 17–18px, line-height about 1.6, never below 16px on a phone. Widths in
%,ch,rem,min()— a pixel width over ~400px is a phone bug waiting to happen.
A skeleton that gets all of this right; start from it rather than from a blank file:
* { box-sizing: border-box; }
body { margin: 0; font-size: 17px; line-height: 1.6; overflow-wrap: anywhere; }
img, video, svg { max-width: 100%; height: auto; }
pre, table { max-width: 100%; overflow-x: auto; }
.layout { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 100vh; }
aside { position: sticky; top: 0; height: 100vh; overflow-y: auto; padding: 2rem 1.5rem; }
main { width: 100%; max-width: 78ch; margin: 0 auto; padding: 3rem 2.5rem 6rem; }
h1 { font-size: clamp(1.8rem, 5vw, 2.6rem); line-height: 1.15; }
@media (max-width: 900px) {
.layout { grid-template-columns: minmax(0, 1fr); }
aside { position: static; height: auto; padding: 1.25rem; }
main { padding: 2rem 1.25rem 4rem; }
}
Drop the aside lines and the 280px column for a page without a sidebar; the rest stays.
Before deploying, run the check yourself and fix whatever it reports:
python3 <skill-dir>/scripts/layout_check.py <site-dir>
It renders the page at 390px and 1440px and prints the numbers: page width versus screen, which elements poke past the edge, and how much empty space sits on each side of the text column. deploy.sh runs it too, and refuses to publish a page that overflows on a phone.
Deploy
bash <skill-dir>/scripts/deploy.sh --dir <site-dir> --repo <repo-name>
That's the whole thing. It audits the site, checks the layout, initializes and commits, creates the repo, enables Pages, waits for the build, and verifies the live URL before printing it. Rerun the same command later to publish updates.
Confirm the repository name with the user first — it becomes the URL path (https://<owner>.github.io/<repo>/) and renaming it later breaks any link they've shared. Public is the default; --private works but needs a paid GitHub plan, and the published site is world-readable either way, so it buys less than people expect.
What it does, and why each part is there
Four commands would technically publish a site. These are the parts that make it work on the first try instead of the third:
Audits the site before pushing (scripts/preflight.py, run automatically). It catches the failures that don't show up until the site is live:
- Root-relative paths like
src="/assets/logo.png". On a project site the leading/resolves tohttps://<owner>.github.io/, not your repo's subpath, so the image 404s. Useassets/logo.png. - Case-only mismatches —
assets/Logo.pngon disk,assets/logo.pngin the HTML. macOS doesn't care, the Pages server does. This one is nearly invisible locally. - References to files that simply aren't there, and a missing
index.html, which makes the whole site 404.
Errors stop the deploy. If the user knowingly wants to publish anyway, --force proceeds.
Renders the page at phone and laptop widths (scripts/layout_check.py, run automatically). A missing viewport meta or a page wider than a 390px phone is an error and stops the deploy; a text column that leaves a big lopsided gap on a 1440px screen, or body text under 15px on the phone, is a note. It uses Playwright's Chromium if installed, falls back to a Chrome binary with a short timeout (Chrome with a pending update can hang at launch), and skips with a message if neither works.
Writes .nojekyll. Pages runs everything through Jekyll by default, which silently skips any path starting with _ or . (so _next/, _assets/ never publish) and treats {{ }} in HTML as template syntax. The empty .nojekyll file turns that off.
Treats 409 on the Pages API as success, and retries the Pages call for a few seconds after repo creation — the endpoint 404s briefly while GitHub finishes provisioning the repo.
Waits for the build, then fetches the site (scripts/verify.py). A fresh build takes 30–90 seconds, during which the URL returns 404 with no holding page. An immediate 404 almost always means "not built yet", so don't interpret one as a failure. Once it's up, verify requests every local file the page references and reports any that don't load.
Report the live URL, the repo URL, and the verify result. Never hand over a URL you haven't seen return 200.
If something is wrong
- Page scrolls sideways on a phone, text cut off at the right —
python3 <skill-dir>/scripts/layout_check.py <site-dir>names the elements sticking out. If it's a<pre>,<table>, or<code>, the column containing it is bare1fr/auto: change it tominmax(0, 1fr)(ormin-width: 0on a flex child) and give the elementoverflow-x: auto. If it's a long URL, addoverflow-wrap: anywhere. If it's an image,max-width: 100%. - Big blank area on the right of a laptop screen — the text column has a
max-widthbut nomargin: 0 auto. Add it (orjustify-self: centerin a grid). The check reports the space on each side so you can see it's balanced afterwards. - Layout check says SKIP — no renderer found.
pip install playwright && playwright install chromium, or setCHROME_BINto a Chrome binary. If Chrome times out, it's usually waiting on its own updater; relaunching Chrome to finish the update clears it. - Still 404 after the build says
built— checkgh api "repos/$OWNER/$REPO/pages" --jq '.source, .html_url'. Usually the URL is missing the/<repo>/path segment, orindex.htmlisn't at the repo root. - Page loads, images don't — rerun
python3 <skill-dir>/scripts/verify.py <url>to get the exact 404s. Fix the paths, then rerundeploy.shto push and recheck. - Build says
errored—gh api "repos/$OWNER/$REPO/pages/builds/latest" --jq .error.message. Nearly always Jekyll choking on something; confirm.nojekyllwas committed (git ls-files .nojekyll) and force a rebuild withgh api --method POST "repos/$OWNER/$REPO/pages/builds". - Repo name already taken — pick another name;
gh repo view <owner>/<name>shows what's already there.