diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml new file mode 100644 index 0000000000..ca65980e84 --- /dev/null +++ b/.github/workflows/deploy-site.yml @@ -0,0 +1,99 @@ +# Build apps/website and publish it to the repo that serves t27.ai. +# +# anomaly-register A51: four PRs merged, every check green, and the live site +# changed nothing for a week. This app deploys from gHashTag/ghashtag.github.io, +# which holds build output only, and a human copied dist/ into it. Nothing in +# the pipeline noticed, because every other check reads the source. +# +# The deploy needs write access to a second repository, which a workflow token +# does not have. It requires a secret named GHIO_TOKEN -- a fine-grained PAT +# with Contents: write on gHashTag/ghashtag.github.io and nothing else. Until +# that secret exists this workflow does not run, and check:deployed keeps +# reporting the gap on every PR. +# +# Manual only, on purpose. Publishing to the live domain on every push to main +# would mean any merge broadcasts, and this project's rule is that content is +# approved before it goes out. +name: Deploy site to t27.ai + +on: + workflow_dispatch: + inputs: + reason: + description: 'What is being published (goes in the commit message)' + required: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: apps/website/package-lock.json + + - name: Build + working-directory: apps/website + run: | + npm ci + npx vite build + + # The same three checks the PR runs, against the tree being published. + # A deploy that skips them is how a broken build reaches the domain. + - name: Checks + working-directory: apps/website + run: | + npm run typecheck:ratchet + npm run check:api + + - name: Checkout the deploy repo + uses: actions/checkout@v4 + with: + repository: gHashTag/ghashtag.github.io + token: ${{ secrets.GHIO_TOKEN }} + path: ghio + + # Copy the build, then delete bundles the new index.html cannot reach. + # Skipping that step is how 74 orphans accumulated once, keeping + # withdrawn claims greppable for weeks after the components were fixed. + - name: Copy the build and drop unreachable bundles + run: | + cp -R apps/website/dist/assets/. ghio/assets/ + cp apps/website/dist/index.html ghio/index.html + cp apps/website/dist/manifest.json ghio/manifest.json + node - <<'EOF' + const {readFileSync, readdirSync, unlinkSync} = require('node:fs'); + const {join} = require('node:path'); + const A = 'ghio/assets'; + const entry = [...readFileSync('ghio/index.html','utf8') + .matchAll(/assets\/([\w.-]+\.(?:js|css))/g)].map(m => m[1]); + const seen = new Set(); const stack = [...entry]; + while (stack.length) { + const f = stack.pop(); + if (seen.has(f)) continue; + seen.add(f); + try { + for (const m of readFileSync(join(A,f),'utf8').matchAll(/["']\.\/([\w.-]+\.(?:js|css))["']/g)) + if (!seen.has(m[1])) stack.push(m[1]); + } catch {} + } + let n = 0; + for (const f of readdirSync(A)) + if (/\.(js|css)$/.test(f) && !seen.has(f)) { unlinkSync(join(A,f)); n++; } + console.log(` ${seen.size} reachable, ${n} orphan(s) removed`); + EOF + + - name: Commit and push + working-directory: ghio + run: | + git config user.name gHashTag + git config user.email admin@t27.ai + git add -A + git diff --cached --quiet && { echo " nothing changed — the live site is already this build"; exit 0; } + git commit -m "Deploy: ${{ inputs.reason }} + + Built from gHashTag/trinity@${{ github.sha }} by the deploy workflow. + See anomaly-register A51 for why this exists." + git push diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml index dd9995fcf5..725b962480 100644 --- a/.github/workflows/website-checks.yml +++ b/.github/workflows/website-checks.yml @@ -78,3 +78,16 @@ jobs: env: CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} run: npm run check:render -- --no-build + + # A51: four PRs merged, every check green, and the live site changed + # nothing -- this app deploys from a second repository that holds build + # output only, copied there by hand. Every check above runs against the + # source, so all of them pass on a tree whose output nobody published. + # + # This one fetches t27.ai and compares the entry bundle. It never fails + # the job: a merge is not a deploy, and blocking one on the other + # inverts the order. It is here so the gap is visible in the log rather + # than discovered a week later in the site's . + - name: Is the live site this build? + continue-on-error: true + run: npm run check:deployed diff --git a/apps/website/package.json b/apps/website/package.json index c6d409dca5..02d5cfe053 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -13,6 +13,7 @@ "typecheck:ratchet": "node scripts/typecheck-ratchet.mjs", "check:api": "node scripts/api-contract-check.mjs", "check:render": "node scripts/render-check.mjs", + "check:deployed": "node scripts/deployed-check.mjs", "check:aria": "node scripts/aria-refs-check.mjs", "typecheck:update": "node scripts/typecheck-ratchet.mjs --update" }, diff --git a/apps/website/scripts/deployed-check.mjs b/apps/website/scripts/deployed-check.mjs new file mode 100644 index 0000000000..fd577df3eb --- /dev/null +++ b/apps/website/scripts/deployed-check.mjs @@ -0,0 +1,62 @@ +// Is what t27.ai serves the same thing this tree builds? +// +// anomaly-register A51: four PRs merged, every check green, and the live site +// changed nothing. The SPA deploys from a second repository that holds build +// output only, and nothing copies a build into it. The site kept serving +// "First chip with native SU(3) Unitary Core" for a week after the tree that +// produces it stopped saying that. +// +// A51 concluded no test in the source repo could see this. That was wrong by +// one assumption: a test that FETCHES THE LIVE SITE can. This is that test. +// +// npm run check:deployed +// +// It compares what the deployed index.html loads against what dist/index.html +// loads. Vite hashes bundle names by content, so equal names mean equal +// bundles and a different name means the deploy is behind -- or ahead, which +// is worth knowing too. +// +// What it cannot tell you: WHICH direction, or whether the difference matters. +// A whitespace change moves the hash. So this warns; it does not fail a build. +// A gate that blocks a merge because someone has not deployed yet inverts the +// order of operations. +import { readFileSync, existsSync } from 'node:fs'; + +const SITE = process.env.DEPLOY_URL ?? 'https://t27.ai/'; +const entryOf = (html) => (html.match(/assets\/(index-[\w-]+\.js)/) ?? [])[1]; + +if (!existsSync('dist/index.html')) { + console.log(' no dist/ — run `npx vite build` first (nothing to compare)'); + process.exit(0); +} +const local = entryOf(readFileSync('dist/index.html', 'utf8')); +if (!local) { + console.error(' no entry bundle in dist/index.html. That is a build problem, not a deploy one.'); + process.exit(1); +} + +let live; +try { + const res = await fetch(SITE, { headers: { 'User-Agent': 'deployed-check' } }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + live = entryOf(await res.text()); +} catch (e) { + // Offline, or the site is down. Neither is a defect in this change, and a + // check that fails when the network does gets muted. + console.log(` could not reach ${SITE} (${e.message}) — skipping`); + process.exit(0); +} + +if (!live) { + console.error(` ${SITE} serves no index-*.js bundle. Either it is not this app, or it is broken.`); + process.exit(1); +} +if (live === local) { + console.log(` deployed: ${live} — the live site is this build`); + process.exit(0); +} +console.log(`\n the live site is NOT this build:\n`); +console.log(` ${SITE.padEnd(28)} ${live}`); +console.log(` dist/index.html ${local}\n`); +console.log(' Merging does not deploy this site. The build output lives in a second'); +console.log(' repository and is copied there by hand — see anomaly-register A51.');