diff --git a/CNAME b/CNAME index 2a97b99..a0b08ba 100644 --- a/CNAME +++ b/CNAME @@ -1 +1 @@ -yellowpine.dev \ No newline at end of file +yellowpine.com diff --git a/README.md b/README.md index 002f3bc..c0fbbb2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/docs/superpowers/specs/2026-09-10-yellowpine-com-apex-design.md b/docs/superpowers/specs/2026-09-10-yellowpine-com-apex-design.md new file mode 100644 index 0000000..1e44c9a --- /dev/null +++ b/docs/superpowers/specs/2026-09-10-yellowpine-com-apex-design.md @@ -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+.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. diff --git a/index.html b/index.html index c7c02ed..3fa869b 100644 --- a/index.html +++ b/index.html @@ -6,11 +6,11 @@ Yellow Pine | Thoughtful tools, built with care - + - - + + diff --git a/package.json b/package.json index 7dfea97..6121b01 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/tests/site.test.mjs b/tests/site.test.mjs index aae2c17..cc13b6a 100644 --- a/tests/site.test.mjs +++ b/tests/site.test.mjs @@ -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 @@ -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'; @@ -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(/ { + // 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>/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 [ '',