diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..f692689 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,35 @@ +name: Validate + +# The same build the deploy runs, before it can reach main. A green check here means the Deploy +# workflow will not be the thing that discovers a broken announcement. +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: validate-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm install --no-audit --no-fund + - run: node build.mjs + - name: Check the feed the products read + run: | + node -e " + const feed = JSON.parse(require('node:fs').readFileSync('dist/alerts.json', 'utf8')); + if (feed.schema !== 'gpv-status/1') throw new Error('alerts.json has the wrong schema'); + if (!Array.isArray(feed.alerts)) throw new Error('alerts.json has no alerts array'); + console.log(\`alerts.json is valid, with \${feed.alerts.length} announcement(s)\`); + " + test -f dist/index.html || { echo 'no index.html was built'; exit 1; } diff --git a/README.md b/README.md index 195b4b9..31d703e 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,25 @@ Then: ```bash npm install # first time only node build.mjs # fails loudly, and lists every problem, if anything is wrong -git add -A && git commit -m "status: controller saving is down" && git push +git checkout -b status/controller-saving-down +git add -A && git commit -m "status: controller saving is down" && git push -u origin HEAD +gh pr create --fill +gh pr checks --watch # the Validate run, about 12 seconds +gh pr merge --squash --delete-branch ``` -Pushing to `main` runs the **Deploy** workflow. **If the build fails, nothing is deployed and the -previous version stays live** — which is also why the build is worth running before you push. +`main` is protected: it takes a pull request, and the **Validate** workflow — the same build, run +against the PR — has to pass before it can merge. Merging then runs the **Deploy** workflow. **If +the build fails, nothing is deployed and the previous version stays live** — which is also why the +build is worth running before you push. + +### When something is actually on fire + +Repository admins can still push straight to `main`, and that is deliberate: this site exists to +say something within seconds, and a PR round trip is the wrong thing to owe an audience that is +already looking at breakage. Use it when the delay genuinely matters — and run `node build.mjs` +first, because the failure mode of skipping it is silent. A bad file fails the deploy, the previous +page stays live, and the alert you were in a hurry to publish simply never appears. ### Resolving an alert @@ -67,12 +81,16 @@ further push from you. Useful for planned maintenance. ## How long it takes to go live -**About 30 seconds from `git push`**, and roughly 90 seconds to a tab that is already open. +**About 30 seconds from the merge**, and roughly 90 seconds to a tab that is already open. Measured on a real change: pushed at 02:53:03, the Deploy workflow ran 02:53:05 to 02:53:31, and the live file changed 32 seconds after the push — about a second after the deploy finished. Deploying is the whole cost. +A pull request adds the **Validate** run ahead of that — 12 seconds, measured on the PR that +introduced it — so an alert published the normal way is live about three quarters of a minute after +you open the PR. An admin pushing straight to `main` is back to the 30 seconds above. + GitHub Pages serves `cache-control: max-age=600` and gives no way to change it, but that does not delay a release: publishing purges the CDN, which is why the new file was served immediately. The 600 seconds only ever applied to browsers reusing their own cache, and the dashboard and overlay @@ -102,6 +120,6 @@ rather than breaking a page. The canonical schema lives in the `gpv` repository, ``` announcements/ one markdown file per incident build.mjs validates frontmatter, writes dist/ -public/ copied verbatim into dist/ (CNAME, .nojekyll) -.github/workflows/ the Pages deploy +public/ copied verbatim into dist/ (CNAME, .nojekyll, the icon) +.github/workflows/ the PR check, and the Pages deploy ``` diff --git a/build.mjs b/build.mjs index facd2af..e5b949e 100644 --- a/build.mjs +++ b/build.mjs @@ -14,6 +14,22 @@ const DIST = join(ROOT, 'dist'); const SITE_URL = 'https://status.gamepadviewer.com'; const SITE_NAME = 'GamePad Viewer status'; +/** + * The GPV mark, from the dashboard, inlined rather than linked: this page has to render when the + * rest of the product does not, so it makes no network request of its own. Monochrome, so it + * follows the text colour in both themes. + */ +const LOGO_G = 'M76.71,58c-6.9,0-10.36-8.35-5.48-13.23l21.87-21.87c3.37-3.37,2.92-8.95-.93-11.76C82.12,3.79,69.6-.4,56.1,.03,26.05,.99,1.44,25.23,.06,55.26c-1.53,33.28,25,60.74,57.94,60.74,11.32,0,21.88-3.25,30.81-8.87,3.04-1.91,7-1.42,9.54,1.11l5.7,5.7h0c1.37,1.37,3.31,2.18,5.43,2.03,3.73-.25,6.52-3.54,6.52-7.27v-42.96c0-4.28-3.47-7.75-7.75-7.75h-31.54Z'; +const LOGO_P = 'M186.66,0h-51.75c-4.28,0-7.75,3.47-7.75,7.75V108.25c0,4.28,3.47,7.75,7.75,7.75h25.5c4.28,0,7.75-3.47,7.75-7.75v-25.5c0-4.28,3.47-7.75,7.75-7.75h10.01c21.03,0,38.64-17.21,38.23-38.23-.39-20.37-17.03-36.77-37.49-36.77Z'; +const LOGO_V = 'M227.73,11.21l50.33,100.67c2.86,5.71,11.01,5.71,13.86,0L342.27,11.21c2.58-5.15-1.17-11.21-6.93-11.21h-100.67c-5.76,0-9.5,6.06-6.93,11.21Z'; + +const LOGO = ``; + +/** The same mark, as the favicon: a data URI, so it cannot 404 and costs no request. */ +const FAVICON = `data:image/svg+xml,${encodeURIComponent( + `` +)}`; + /** * Site-wide, and deliberately not part of any alert: this page is where people land when the * dashboard itself is unreachable, so it has to carry the ways to reach us on its own. @@ -172,16 +188,22 @@ const linkList = (links) => const block = (heading, text) => (text ? `

${heading}

${escape(text)}

` : ''); const STYLE = ` -:root { --bg:#0f1115; --card:#171a21; --line:#272b35; --text:#e7e9ee; --muted:#9aa3b2; +:root { --bg:#0a0a0a; --card:#141414; --line:#2a2a2a; --text:#fafafa; --muted:#a1a1aa; --info:#4c8dff; --warning:#e9a23b; --outage:#e5534b; color-scheme: dark; } @media (prefers-color-scheme: light) { - :root { --bg:#f7f8fa; --card:#fff; --line:#e3e6eb; --text:#1b1f27; --muted:#5d6675; color-scheme: light; } + :root { --bg:#fafafa; --card:#fff; --line:#e5e5e5; --text:#141414; --muted:#6b7280; color-scheme: light; } } * { box-sizing: border-box; } body { margin:0; padding:32px 16px 64px; background:var(--bg); color:var(--text); font:16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } main { max-width: 680px; margin: 0 auto; } h1 { font-size: 1.5rem; margin: 0 0 24px; } +.brand { display:flex; align-items:center; gap:10px; font-weight:600; letter-spacing:-.01em; } +.brand .logo { height:1.05em; width:auto; fill:currentColor; display:block; } +.brand .word { color:var(--muted); font-weight:500; } +h1.brand { margin:0 0 26px; } +a.brand { display:flex; width:fit-content; font-size:1rem; text-decoration:none; margin-bottom:22px; color:var(--muted); } +a.brand .logo { fill:var(--muted); } h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 32px 0 12px; } a { color: inherit; } @@ -208,8 +230,7 @@ a { color: inherit; } .archive time { color:var(--muted); white-space:nowrap; flex:0 0 7.5em; } footer { margin-top:40px; padding-top:20px; border-top:1px solid var(--line); color:var(--muted); font-size:.9rem; } -footer a { margin-right:14px; } -.back { display:inline-block; color:var(--muted); text-decoration:none; margin-bottom:20px; } +footer a { display:inline-block; margin-right:14px; white-space:nowrap; } .body { margin-top:8px; border-top:1px solid var(--line); padding-top:20px; } `; @@ -218,13 +239,29 @@ const footer = () => ({ label, href }) => `${escape(label)}` ).join('')}`; -const page = (title, body) => ` +/** Trimmed for the places that show a page's description: search results, and a Discord unfurl. */ +const summarise = (text) => (text.length > 180 ? `${text.slice(0, 177).trimEnd()}...` : text); + +const page = (title, body, { description, url = SITE_URL }) => ` ${escape(title)} + + + + + + + + + + + + +
${body}${footer()}
@@ -272,14 +309,22 @@ function indexPage(announcements, now) { .join('')}` : ''; - return page(SITE_NAME, `

${SITE_NAME}

${current}${archive}`); + const description = active.length + ? `${active.length} active alert${active.length === 1 ? '' : 's'}: ${active + .map((alert) => alert.title) + .join(' \u00b7 ')}` + : 'All GamePad Viewer systems normal.'; + + return page(SITE_NAME, `

${LOGO}status

${current}${archive}`, { + description + }); } function detailPage(alert) { const ongoing = !alert.endsAt; return page( `${alert.title} · ${SITE_NAME}`, - `← ${SITE_NAME} + `${LOGO}status ${escape(SEVERITY_LABEL[alert.severity])}

${escape(alert.title)}

Started ${escape(formatDate(alert.startsAt))}${ @@ -287,7 +332,8 @@ function detailPage(alert) { }

${block('What you can do', alert.workaround)} ${alert.links.length ? '

Links

' : ''}${linkList(alert.links)} -
${marked.parse(alert.body)}
` +
${marked.parse(alert.body)}
`, + { description: alert.summary, url: detailUrlFor(alert) ?? SITE_URL } ); } diff --git a/public/icon-256.png b/public/icon-256.png new file mode 100644 index 0000000..915bbaf Binary files /dev/null and b/public/icon-256.png differ