Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CNAME
Original file line number Diff line number Diff line change
@@ -1 +1 @@
yellowpine.dev
yellowpine.com
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# yellow-pine.github.io — yellowpine.dev
# yellow-pine.github.io — yellowpine.com

The Yellow Pine website. One hand-written `index.html`, served by GitHub Pages at
[yellowpine.dev](https://yellowpine.dev).
[yellowpine.com](https://yellowpine.com).

It is a **hook page**, not a brochure: who we are, what we have shipped, how to reach us.
Detail lives on [github.com/yellow-pine](https://github.com/yellow-pine), the way
[cansin.dev](https://cansin.dev) defers to its own profile.

`yellowpine.com` is unrelated to this repo and still redirects to the GitHub org.
`yellowpine.dev` is the redirect domain: it 301s here, apex and `www` alike. The apex moved
from `.dev` to `.com` on 2026-09-10, once `yellowpine.com` was finally ours.

## Contents

Expand Down Expand Up @@ -59,6 +60,24 @@ SKIP_NETWORK=1 npm test # offline: skip link-liveness

## Deploying

Pages serves the default branch root. `CNAME` sets the domain. `.dev` is HSTS-preloaded, so
the site is HTTPS-only by construction — there is no HTTP fallback to configure. After the
DNS records point at GitHub, enable **Enforce HTTPS** once the certificate issues.
Pages serves the default branch root. `CNAME` sets the domain.

**Point DNS at GitHub _before_ the domain lands in `CNAME`.** If Pages registers a custom
domain whose DNS still resolves elsewhere, certificate validation fails and GitHub never
retries on its own: Pages keeps serving over HTTP and `https_certificate` is absent entirely.

To recover, unset the custom domain and set it again, which re-runs validation:

```sh
gh api -X PUT repos/yellow-pine/yellow-pine.github.io/pages -F cname=null
gh api -X PUT repos/yellow-pine/yellow-pine.github.io/pages -f cname=yellowpine.com
```

Note `-F`, not `-f`: the API removes the domain only on a JSON `null`, and `-f` would send
the literal string `"null"`. Enable **Enforce HTTPS** in a separate call once the certificate
issues — passing `https_enforced` alongside `cname` 404s with "certificate does not exist
yet".

Unlike the old `.dev` apex, `.com` is not HSTS-preloaded, so HTTPS is not free here. **Enforce
HTTPS** is what redirects HTTP to HTTPS, and it has to be switched on once the certificate
issues rather than assumed.
76 changes: 76 additions & 0 deletions docs/superpowers/specs/2026-09-10-yellowpine-com-apex-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# yellowpine.com as the apex - design

**Date:** 2026-09-10
**Status:** approved, implementing

## Purpose

`yellowpine.com` is finally in our Porkbun account. It is the name people guess, the name on
the career record, and the name the org profile already prints. This makes it the apex: the
site moves from `yellowpine.dev` to `yellowpine.com`, `.dev` becomes the redirect domain, and
the mailboxes move with it.

It reverses the topology chosen on 2026-09-08, which parked the site on `.dev` precisely
because `.com` was not ours to point anywhere. That constraint is gone.

## The defect this closes

The page has advertised `hello@yellowpine.com` since 2026-09-08, in the masthead and again
under *Get in touch*. `yellowpine.com` was not ours then, and when it arrived it came with the
previous owner's Mailgun MX records and zero forwards of ours. **Every message sent to the
address on the homepage went to servers we do not control.** Nothing in the suite caught it,
because no test related the contact address to the domain the site is served from.

`tests/site.test.mjs` now asserts that relationship, and it was written failing against the
live page before anything was changed.

## Decisions taken

| Question | Decision |
|---|---|
| Apex | `yellowpine.com`. GitHub Pages, this repo, `CNAME` + 4 A records + `www` CNAME. |
| `yellowpine.dev` | Redirect domain. Porkbun URL forwarding, 301, wildcard, path preserved. Kept, not dropped: it is in print and in the git history. |
| Email | The 16 aliases move to `@yellowpine.com`, same convention (`cansinyildiz+<alias>.yellowpine@gmail.com`). `.dev` forwarding is removed, so `.dev` is redirect-only. |
| Previous owner's records | Mailgun MX pair, Mailgun SPF, and the two `_acme-challenge` TXT records deleted. The TXT pair validated the wildcard certificate for the URL forwarding we are removing. |
| The 2026-09-08 spec | Left as written. It records what was true then; this supersedes rather than edits it. |

## Non-goals

- No redesign. Identity v2.2 and the page itself are untouched apart from three URLs.
- The free-trial hosted inbox pending setup on `.dev` is neither set up nor cancelled; it
lapses on its own on 2026-09-22. Removing `.dev`'s MX and SPF does mean nothing reaches it
in the meantime, which is the point of making `.dev` redirect-only.
- No new aliases. The 16 are copied across exactly.

## Order of operations

Pages serves exactly one custom domain, so the two domains cannot both be correct at once.
The sequence puts the unavoidable gap on the domain being retired:

1. `yellowpine.com` DNS -> GitHub Pages (A records, `www`), Porkbun mail (MX, SPF). Previous
owner's records deleted. `.dev` is untouched and fully live throughout.
2. Porkbun URL forward removed from `.com`.
3. The 16 forwards created on `.com`, in the UI - Porkbun's public API has no email endpoints.
4. Confirm `.com` resolves to GitHub before the domain lands in `CNAME`. Registering a custom
domain whose DNS still points elsewhere fails validation, and GitHub never retries.
5. Merge -> Pages re-registers on `.com`. Verify the certificate reaches `approved`, then
enable `https_enforced` in its own call.
6. Immediately after: `.dev` A records, `www`, MX, SPF and forwards deleted; URL forward added.

**Known transitional risk.** Between 5 and Porkbun issuing a certificate for `yellowpine.dev`
in 6, `.dev` is dark. `.dev` is HSTS-preloaded at the TLD level, so it hard-fails in browsers
with no HTTP fallback to degrade to. This is why `.dev` is flipped last and only once `.com`
is confirmed serving: the outage lands on the domain being retired, never on the live one.

## Verification

`yellowpine.com` serves 200 over valid HTTPS; `www.yellowpine.com` reaches it; `yellowpine.dev`
and `www.yellowpine.dev` 301 to it over valid HTTPS; mail to an alias at `@yellowpine.com`
arrives.

**None of that is covered by `npm test`.** The suite asserts the repo's own invariants —
`CNAME`, canonical, `og:*`, the contact address, the brand guardrails — and its only network
checks are the `github.com/yellow-pine/*` links and the product links. The apex is explicitly
excluded from the liveness test by `isSelf`, so a certificate that never issues, or a `.dev`
forward that leaves the domain dark, would keep CI green indefinitely. The four checks above
are manual, by `curl`, and are the real gate for this change.
6 changes: 3 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
<meta name="color-scheme" content="light dark">
<title>Yellow Pine | Thoughtful tools, built with care</title>
<meta name="description" content="Yellow Pine is a small product studio: a tiny human team and a fleet of AI agents. Latch, kishi, bly, and the developer tools we needed ourselves and released.">
<link rel="canonical" href="https://yellowpine.dev/">
<link rel="canonical" href="https://yellowpine.com/">
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4MzQgODM0IiByb2xlPSJpbWciIGFyaWEtbGFiZWw9IlllbGxvdyBQaW5lIGljb24iPgogIDx0aXRsZT5ZZWxsb3cgUGluZSBpY29uPC90aXRsZT4KICA8cmVjdCB3aWR0aD0iODM0IiBoZWlnaHQ9IjgzNCIgcng9IjE4NiIgZmlsbD0iIzIwMjAyMCIvPgogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEwNi4yLDY1LjgpIHNjYWxlKDEuMjY1KSI+PHBhdGggZmlsbD0iI0ZGRDEwMCIgZD0iTTI0OS40LDc4LjlRMjcwLjksNDAuNSAyODYuOCw4MS41TDM0Mi43LDIyNi4xUTM1MC43LDI0Ni42IDMyOC43LDI0NS4xTDE4NS4xLDIzNVExNjMuMSwyMzMuNSAxNzMuOSwyMTQuM1oiLz48cGF0aCBmaWxsPSIjRkZEMTAwIiBkPSJNMjM2LjksMTc1UTI2My45LDE0MC4zIDI4NS44LDE3OC40TDQxMy44LDQwMS4xUTQyNC43LDQyMC4yIDQwMi44LDQxOC42TDI5My4xLDQxMVEyODMuMSw0MTAuMyAyODIuNCw0MjAuMkwyNzkuOCw0NTcuMlEyNzguOCw0NzIuMSAyNjMuOCw0NzEuMUwyMTcuOSw0NjcuOVEyMDMsNDY2LjggMjA0LDQ1MS45TDIwNi42LDQxNC45UTIwNy4zLDQwNSAxOTcuMyw0MDQuM0w4Ny42LDM5Ni42UTY1LjYsMzk1LjEgNzkuMSwzNzcuN1oiLz48L2c+Cjwvc3ZnPgo=">
<meta property="og:type" content="website">
<meta property="og:site_name" content="yellowpine.dev">
<meta property="og:url" content="https://yellowpine.dev/">
<meta property="og:site_name" content="yellowpine.com">
<meta property="og:url" content="https://yellowpine.com/">
Comment on lines +12 to +13
<meta property="og:title" content="Yellow Pine — thoughtful tools, built with care">
<meta property="og:description" content="A small product studio: a tiny human team and a fleet of AI agents. Latch, kishi, bly, and the developer tools we needed ourselves and released.">
<meta name="twitter:card" content="summary">
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@yellow-pine/website",
"private": true,
"description": "The Yellow Pine website (yellowpine.dev) - one hand-written page, served by GitHub Pages.",
"description": "The Yellow Pine website (yellowpine.com) - one hand-written page, served by GitHub Pages.",
"type": "module",
"scripts": {
"test": "node --test tests/site.test.mjs"
Expand Down
59 changes: 52 additions & 7 deletions tests/site.test.mjs
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
// Invariants for the Yellow Pine website (index.html), served at yellowpine.dev.
// Invariants for the Yellow Pine website (index.html), served at yellowpine.com.
//
// The site is a single hand-written file with no build step, so there is no compiler to
// catch mistakes. These tests are the gate instead:
//
// 1. CNAME says exactly yellowpine.dev, and the page's canonical URL agrees.
// 2. Every github.com/yellow-pine/* link is reachable WITHOUT auth — the publish rule
// 1. CNAME says exactly yellowpine.com, and the page's canonical URL agrees.
// 2. The contact address is on that same domain. These are one fact in two places, and
// they did drift: the page advertised hello@yellowpine.com for two days while the
// site served yellowpine.dev and the .com MX belonged to the previous owner.
// 3. Every github.com/yellow-pine/* link is reachable WITHOUT auth — the publish rule
// expressed without naming any repo: a link to a private repo 404s for the anonymous
// public and fails here, so nothing private can leak onto the site.
// 3. Every product link is live (a dead product link is worse than no link).
// 4. The page has no external asset dependencies beyond Google Fonts — the mark and
// 4. Every product link is live (a dead product link is worse than no link).
// 5. The page has no external asset dependencies beyond Google Fonts — the mark and
// favicon must stay inline, and the wordmark is live type rather than outlines.
// 5. Brand v2.2 guardrails hold: the identity yellow appears only inside the inline mark
// 6. Brand v2.2 guardrails hold: the identity yellow appears only inside the inline mark
// and the selection wash, and focus rings are azure, never yellow.
//
// Zero dependencies: node:test + global fetch (Node >= 20). Network checks honor
Expand All @@ -25,7 +28,7 @@ import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const read = (p) => readFileSync(join(repoRoot, p), 'utf8');

const DOMAIN = 'yellowpine.dev';
const DOMAIN = 'yellowpine.com';
const html = read('index.html');
const skipNetwork = process.env.SKIP_NETWORK === '1';

Expand Down Expand Up @@ -83,6 +86,48 @@ test('page declares the canonical URL and matches CNAME', () => {
assert.equal(new URL(canonical).hostname, DOMAIN);
});

test('the contact address is on the domain the site serves', () => {
// A mailto on some other domain is a dead address the moment that domain stops being
// ours to route: the reader sees an invitation to write, and the mail lands nowhere.
// Contact domain and served domain are one fact, so they are asserted as one.
const mailtos = allHrefs.filter((h) => h.startsWith('mailto:'));
assert.ok(mailtos.length > 0, 'the page should offer a way to reach us');
for (const href of mailtos) {
// Strip any ?subject=/?body= tail, or the domain check reads it as part of the host.
const address = href.slice('mailto:'.length).split('?')[0];
assert.equal(
address.split('@')[1],
DOMAIN,
`contact address is not on ${DOMAIN}: ${address}`,
);
}
});

test('the social metadata points at the same domain', () => {
// hrefsIn() only sees href=/src=, so og:url never reaches the mailto and canonical
// checks above, and the metadata test below only asserts these tags EXIST, never what
// they say. Without this, a domain move can update CNAME and canonical, miss og:*, and
// still go green - while every shared link and social preview advertises the old domain.
const ogUrl = html.match(/<meta property="og:url" content="([^"]+)"/)?.[1];
assert.ok(ogUrl, 'no og:url');
assert.equal(new URL(ogUrl).hostname, DOMAIN);

const ogSite = html.match(/<meta property="og:site_name" content="([^"]+)"/)?.[1];
assert.ok(ogSite, 'no og:site_name');
assert.equal(ogSite, DOMAIN);
});

test('the contact address reads the same as it links', () => {
// The address is a link target AND visible text. Edit one without the other and the
// suite stays green while readers who copy it by eye, and crawlers reading the text,
// get the dead domain.
const links = [...html.matchAll(/<a href="mailto:([^"]+)"[^>]*>([^<]+)<\/a>/g)];
assert.ok(links.length > 0, 'expected at least one mailto link');
for (const [, target, text] of links) {
assert.equal(text.trim(), target.split('?')[0], 'anchor text must match the mailto target');
}
});

test('page has the metadata a shared link needs', () => {
for (const needle of [
'<!doctype html>',
Expand Down
Loading