diff --git a/.github/workflows/brain-ci.yml b/.github/workflows/brain-ci.yml index 9403b3eca8..5c21ce3486 100644 --- a/.github/workflows/brain-ci.yml +++ b/.github/workflows/brain-ci.yml @@ -1,13 +1,25 @@ name: S³AI Brain CI — "Functional MRI" Gate +# Managed by apps/website/scripts/ci-triggers-from-spec.mjs from specs/ci/workflow_triggers.t27. +# Do not edit this block: edit the spec, then run npm --prefix apps/website run gen:ci-triggers. on: push: - branches: [main, v*, 'feat/*', 'fix/*', 'refactor/*'] + branches: [main, 'v*', 'feat/*', 'fix/*', 'refactor/*'] + # No schedule: block. The Queen (Inngest app t27-queen) dispatches this workflow + # on '0 0 * * 0' UTC from the card specs/crons/trinity-brain-ci.t27 in gHashTag/t27. + # The brain is Zig in src/; a website or docs change cannot move its + # health score, and the site carries its own checks. + paths-ignore: + - 'apps/website/**' + - 'docs/**' + - '**/*.md' pull_request: - branches: [main, v*] + branches: [main, 'v*'] + paths-ignore: + - 'apps/website/**' + - 'docs/**' + - '**/*.md' workflow_dispatch: - # No schedule: block. The Queen (Inngest app t27-queen) dispatches this workflow - # on '0 0 * * 0' UTC from the card specs/crons/trinity-brain-ci.t27 in gHashTag/t27. env: BRAIN_HEALTH_THRESHOLD: "80" # Minimum health score for merge diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 522a66d8cd..d6dc13f2ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,26 @@ name: CI +# Managed by apps/website/scripts/ci-triggers-from-spec.mjs from specs/ci/workflow_triggers.t27. +# Do not edit this block: edit the spec, then run npm --prefix apps/website run gen:ci-triggers. on: push: branches: [main] + # A website or docs change ships no binary: the site carries its own + # checks (website-checks.yml), and this build would test an unchanged + # Zig tree twice for prose. + paths-ignore: + - 'apps/website/**' + - 'docs/**' + - '**/*.md' pull_request: branches: [main] + paths-ignore: + - 'apps/website/**' + - 'docs/**' + - '**/*.md' + # So this workflow's verdict can be refreshed behind a path filter; + # without it, its status is whatever its last accepted run concluded. + workflow_dispatch: jobs: test: diff --git a/.github/workflows/codegen.yml b/.github/workflows/codegen.yml index 48f684c6bf..25edb61ed8 100644 --- a/.github/workflows/codegen.yml +++ b/.github/workflows/codegen.yml @@ -1,12 +1,24 @@ name: Codegen Validation +# Managed by apps/website/scripts/ci-triggers-from-spec.mjs from specs/ci/workflow_triggers.t27. +# Do not edit this block: edit the spec, then run npm --prefix apps/website run gen:ci-triggers. on: push: - branches: [main, ralph/dev-*, vibee-v5-production] + branches: [main, 'ralph/dev-*', vibee-v5-production] + # No schedule: block. The Queen (Inngest app t27-queen) dispatches this workflow + # on '0 0 * * *' UTC from the card specs/crons/trinity-codegen.t27 in gHashTag/t27. + # Codegen consumes specs and the compiler in src/, not prose or the + # website; a docs-only merge regenerates byte-identical output. + paths-ignore: + - 'apps/website/**' + - 'docs/**' + - '**/*.md' pull_request: branches: [main] - # No schedule: block. The Queen (Inngest app t27-queen) dispatches this workflow - # on '0 0 * * *' UTC from the card specs/crons/trinity-codegen.t27 in gHashTag/t27. + paths-ignore: + - 'apps/website/**' + - 'docs/**' + - '**/*.md' workflow_dispatch: jobs: diff --git a/.github/workflows/koschei-production.yml b/.github/workflows/koschei-production.yml index 245ce889f1..d4533993db 100644 --- a/.github/workflows/koschei-production.yml +++ b/.github/workflows/koschei-production.yml @@ -4,15 +4,23 @@ name: KOSCHEI Production Deploy +# Managed by apps/website/scripts/ci-triggers-from-spec.mjs from specs/ci/workflow_triggers.t27. +# Do not edit this block: edit the spec, then run npm --prefix apps/website run gen:ci-triggers. on: push: branches: [main] + # A production deploy follows the code and images the swarm is built + # from, not prose: a website or docs merge ships nothing to KOSCHEI. + paths-ignore: + - 'apps/website/**' + - 'docs/**' + - '**/*.md' workflow_dispatch: inputs: environment: description: 'Deployment environment' required: true - default: 'production' + default: production type: choice options: - staging diff --git a/.github/workflows/reproduce.yml b/.github/workflows/reproduce.yml index 94c923954b..c54099812f 100644 --- a/.github/workflows/reproduce.yml +++ b/.github/workflows/reproduce.yml @@ -9,25 +9,41 @@ name: Reproduce the headless profile # is uploaded as the artifact reproduce-headless-, with the capability index of # tools/capabilities.py built on it (capabilities-.json, and a table in the job summary). +# Managed by apps/website/scripts/ci-triggers-from-spec.mjs from specs/ci/workflow_triggers.t27. +# Do not edit this block: edit the spec, then run npm --prefix apps/website run gen:ci-triggers. on: - pull_request: + push: branches: [main] + # The same inputs as the pull_request block: without them, every + # merge to main -- a blog post included -- rebuilt the headless profile + # from a cold cache to prove nothing new. paths: - - "build.zig" - - "build.zig.zon" - - "src/**" - - "external/**" - - "deploy/**/*.zig" - - "tools/reproduce.py" - - "tools/capabilities.py" - - "tools/contracts.py" - - "specs/reproduce/**" - - ".github/workflows/reproduce.yml" - push: + - 'build.zig' + - 'build.zig.zon' + - 'src/**' + - 'external/**' + - 'deploy/**/*.zig' + - 'tools/reproduce.py' + - 'tools/capabilities.py' + - 'tools/contracts.py' + - 'specs/reproduce/**' + - '.github/workflows/reproduce.yml' + pull_request: branches: [main] - schedule: - - cron: "23 4 * * 1" + paths: + - 'build.zig' + - 'build.zig.zon' + - 'src/**' + - 'external/**' + - 'deploy/**/*.zig' + - 'tools/reproduce.py' + - 'tools/capabilities.py' + - 'tools/contracts.py' + - 'specs/reproduce/**' + - '.github/workflows/reproduce.yml' workflow_dispatch: + schedule: + - cron: '23 4 * * 1' jobs: headless: diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml index e11f991218..0c08b7b1fc 100644 --- a/.github/workflows/website-checks.yml +++ b/.github/workflows/website-checks.yml @@ -33,12 +33,18 @@ name: Website checks # The workflow runs on EVERY pull request and decides in `scope` whether the -# site is touched; the heavy `checks` job runs only then, and `website gate` -# always reports. A path filter on the trigger made the check absent on most -# PRs, so it could not be required, and #1535 (a catalog refresh that deleted -# 40 course lesson specs) merged 70 s after it opened with no site check at all. -# `website gate` is the one name branch protection requires: green when the -# checks passed, or when the change touches nothing they read. +# site is touched; the five `checks-*` jobs run only then, in parallel, and +# `website gate` always reports. A path filter on the trigger made the check +# absent on most PRs, so it could not be required, and #1535 (a catalog +# refresh that deleted 40 course lesson specs) merged 70 s after it opened +# with no site check at all. `website gate` is the one name branch protection +# requires: green when the checks passed, or when the change touches nothing +# they read. +# +# Five groups, because the serial `checks` job took 26 minutes and the groups +# take the slowest of them (~8): the pure gates in one, one language audit +# per locale, and two Chrome groups over the built site. A group that needs +# dist/ builds its own; nothing crosses between groups. # # SITE_PATHS: apps/queen-proxy/** is in because one of the gates below reads # it. The proxy is the server half of the rule that only registered people @@ -46,6 +52,7 @@ name: Website checks # otherwise touch no watched path and run no check at all. The five src/tri and # src/registry files are in for the same reason: check:tools-coverage re-reads # the dispatcher, and a new `tri` command must not land without its card. + on: pull_request: push: @@ -91,24 +98,33 @@ jobs: gate: name: website gate - needs: [scope, checks] + needs: [scope, checks-pure, checks-lang-en, checks-lang-ru, checks-browser-a, checks-browser-b] if: always() runs-on: ubuntu-latest steps: - env: SCOPE: ${{ needs.scope.result }} SITE: ${{ needs.scope.outputs.site }} - CHECKS: ${{ needs.checks.result }} + PURE: ${{ needs.checks-pure.result }} + LANG_EN: ${{ needs.checks-lang-en.result }} + LANG_RU: ${{ needs.checks-lang-ru.result }} + BROWSER_A: ${{ needs.checks-browser-a.result }} + BROWSER_B: ${{ needs.checks-browser-b.result }} run: | - echo "scope=$SCOPE site=$SITE checks=$CHECKS" + echo "scope=$SCOPE site=$SITE groups=$PURE,$LANG_EN,$LANG_RU,$BROWSER_A,$BROWSER_B" [ "$SCOPE" = success ] || { echo "the scope job did not finish"; exit 1; } if [ "$SITE" = true ]; then - [ "$CHECKS" = success ] || { echo "the site changed and its checks did not pass"; exit 1; } + for r in "$PURE" "$LANG_EN" "$LANG_RU" "$BROWSER_A" "$BROWSER_B"; do + [ "$r" = success ] || { echo "the site changed and its checks did not pass ($r)"; exit 1; } + done else - [ "$CHECKS" = skipped ] || { echo "the checks ran where nothing needed them: $CHECKS"; exit 1; } + for r in "$PURE" "$LANG_EN" "$LANG_RU" "$BROWSER_A" "$BROWSER_B"; do + [ "$r" = skipped ] || { echo "checks ran where nothing needed them: $r"; exit 1; } + done fi - checks: + checks-pure: + name: checks (pure gates) needs: scope if: needs.scope.outputs.site == 'true' runs-on: ubuntu-latest @@ -481,50 +497,47 @@ jobs: # provider that rewrites them after load, and in og/og-image.svg. - name: Link preview words run: npm run check:og-preview + # The `on:` blocks of the five paying workflows are data in + # specs/ci/workflow_triggers.t27, read through the compiler; this gate + # fails when a committed trigger block is not what the spec says, so a + # parked hand edit to a workflow's triggers cannot come back quietly. + - name: CI triggers match the spec + run: npm run check:ci-triggers + checks-lang-en: + name: checks (language audits en) + needs: scope + if: needs.scope.outputs.site == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/website + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' # render-check uses the WebSocket client built into 22 + cache: npm + cache-dependency-path: apps/website/package-lock.json + + - run: npm ci - name: Build run: npx vite build - # What a crawler reads on the home page before any script runs: the - # lib/motto words GameHero renders, and absolute links to every static - # section. After the build, so the shell is checked in dist/ as shipped. - - name: Home page shell a crawler reads - run: npm run check:boot-shell - # Skips itself if no Chrome is found rather than failing: a missing # browser is an environment fact, not a defect in the change. - uses: browser-actions/setup-chrome@v1 id: chrome - # The WARS view must own its scrolling at desktop and phone widths and - # must remain stable with reduced motion. The build above is the exact - # artifact this browser contract inspects. The other Queen views are not - # claimed here: main fails its own matrix on them (the comb embedded nine - # levels deep, the head row overflowing at 1272-1280, the hive display - # cut by its own boxes), so VIEWS in the contract is scoped to wars until - # the HUD is re-laid-out -- then the whole shell comes back. - - name: Queen viewport matrix - env: - CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} - run: npm run check:queen-viewport -- --no-build - - # The same contract on the built page: every Queen tab's root, the SPECS - # rung and the Explorer frame report one data-corpus-version, the manifest - # is fetched once between shell and frame, and world= in the shell's - # address narrows the frame to that world's count on the comb. - - name: Queen spec corpus, every tab - env: - CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} - run: npm run check:queen-spec-sync -- --browser --no-build - # Проверяет все маршруты в обеих локалях. Скрипты имеют собственные # самотесты и используют Chrome через CDP без новой зависимости npm. - - name: Language audits + # One job per locale: the two audits together were the longest step of + # the serial job (8m50s of the 26m); split, neither holds up the other. + - name: Language audits (en) env: CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} run: | npm run audit:en -- --selftest - npm run audit:ru -- --selftest python3 -m http.server 4173 --directory dist >/tmp/t27-language-audit-server.log 2>&1 & SERVER_PID=$! trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT @@ -534,15 +547,103 @@ jobs: done curl --fail --silent http://127.0.0.1:4173/index.html >/dev/null AUDIT_BASE_URL='http://127.0.0.1:4173/index.html?lang=en' npm run audit:en - AUDIT_BASE_URL='http://127.0.0.1:4173/index.html?lang=ru' npm run audit:ru # Same server, same Chrome harness, one more question: does any route # scroll sideways at 375px. Runs its own --selftest first. npm run audit:mobile -- --selftest + AUDIT_BASE_URL='http://127.0.0.1:4173/index.html' npm run audit:mobile + checks-lang-ru: + name: checks (language audits ru) + needs: scope + if: needs.scope.outputs.site == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/website + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' # render-check uses the WebSocket client built into 22 + cache: npm + cache-dependency-path: apps/website/package-lock.json + + - run: npm ci + + - name: Build + run: npx vite build + + # Skips itself if no Chrome is found rather than failing: a missing + # browser is an environment fact, not a defect in the change. + - uses: browser-actions/setup-chrome@v1 + id: chrome + + - name: Language audits (ru) + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: | + npm run audit:ru -- --selftest + python3 -m http.server 4173 --directory dist >/tmp/t27-language-audit-server.log 2>&1 & + SERVER_PID=$! + trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT + for attempt in 1 2 3 4 5; do + curl --fail --silent http://127.0.0.1:4173/index.html >/dev/null && break + sleep 1 + done + curl --fail --silent http://127.0.0.1:4173/index.html >/dev/null + AUDIT_BASE_URL='http://127.0.0.1:4173/index.html?lang=ru' npm run audit:ru # Both locales: the Russian chrome is wider than the English, and the # /specs language switcher was pushed off a 375px screen in RU while # fitting in EN. A single-locale run would have passed it. - AUDIT_BASE_URL='http://127.0.0.1:4173/index.html' npm run audit:mobile AUDIT_BASE_URL='http://127.0.0.1:4173/index.html?lang=ru' npm run audit:mobile + checks-browser-a: + name: checks (explorer, render, corpus) + needs: scope + if: needs.scope.outputs.site == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/website + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' # render-check uses the WebSocket client built into 22 + cache: npm + cache-dependency-path: apps/website/package-lock.json + + - run: npm ci + + - name: Build + run: npx vite build + + # What a crawler reads on the home page before any script runs: the + # lib/motto words GameHero renders, and absolute links to every static + # section. After the build, so the shell is checked in dist/ as shipped. + - name: Home page shell a crawler reads + run: npm run check:boot-shell + + # Skips itself if no Chrome is found rather than failing: a missing + # browser is an environment fact, not a defect in the change. + - uses: browser-actions/setup-chrome@v1 + id: chrome + + # The five explorers and the spec explorer at every size in the spec's + # matrix: one pane on a phone, two above, the deep-linked card open and + # on screen, 44px controls where a finger is the pointer, one scroller + # per pane, nothing sideways. Skips itself without Chrome. + - name: Explorer viewport contract + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: npm run check:explorer-viewport -- --no-build + + # The same contract on the built page: every Queen tab's root, the SPECS + # rung and the Explorer frame report one data-corpus-version, the manifest + # is fetched once between shell and frame, and world= in the shell's + # address narrows the frame to that world's count on the comb. + - name: Queen spec corpus, every tab + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: npm run check:queen-spec-sync -- --browser --no-build - name: Render check env: @@ -559,23 +660,6 @@ jobs: CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} run: npm run check:home-no-webgl -- --no-build - # The five explorers and the spec explorer at every size in the spec's - # matrix: one pane on a phone, two above, the deep-linked card open and - # on screen, 44px controls where a finger is the pointer, one scroller - # per pane, nothing sideways. Skips itself without Chrome. - - name: Explorer viewport contract - env: - CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} - run: npm run check:explorer-viewport -- --no-build - - # Real touch events through CDP on the built board: a tap selects a unit, - # a drag pans, two fingers zoom. It exits 2 (NOT MEASURED) when the page - # does not answer a probe in 20 s, which on a runner is a failure too. - - name: Queen touch contract - env: - CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} - run: npm run check:queen-touch -- --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 @@ -588,6 +672,51 @@ jobs: - name: Is the live site this build? continue-on-error: true run: npm run check:deployed + checks-browser-b: + name: checks (queen matrix, touch, dead API) + needs: scope + if: needs.scope.outputs.site == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/website + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' # render-check uses the WebSocket client built into 22 + cache: npm + cache-dependency-path: apps/website/package-lock.json + + - run: npm ci + + - name: Build + run: npx vite build + + # Skips itself if no Chrome is found rather than failing: a missing + # browser is an environment fact, not a defect in the change. + - uses: browser-actions/setup-chrome@v1 + id: chrome + + # The WARS view must own its scrolling at desktop and phone widths and + # must remain stable with reduced motion. The build above is the exact + # artifact this browser contract inspects. The other Queen views are not + # claimed here: main fails its own matrix on them (the comb embedded nine + # levels deep, the head row overflowing at 1272-1280, the hive display + # cut by its own boxes), so VIEWS in the contract is scoped to wars until + # the HUD is re-laid-out -- then the whole shell comes back. + - name: Queen viewport matrix + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: npm run check:queen-viewport -- --no-build + + # Real touch events through CDP on the built board: a tap selects a unit, + # a drag pans, two fingers zoom. It exits 2 (NOT MEASURED) when the page + # does not answer a probe in 20 s, which on a runner is a failure too. + - name: Queen touch contract + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: npm run check:queen-touch -- --no-build # Last, because it rebuilds dist/ against an address that refuses every # connection: with the API dead, no tile may show a bare number and the diff --git a/apps/website/package.json b/apps/website/package.json index 9eca2a1efa..a8762414a3 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -114,6 +114,8 @@ "test:docs-specs": "node --test scripts/docs-from-specs.test.mjs", "check:viewport": "node scripts/viewport-from-spec.mjs --check", "check:onboarding": "node scripts/onboarding-from-spec.mjs --check", + "gen:ci-triggers": "node scripts/ci-triggers-from-spec.mjs", + "check:ci-triggers": "node scripts/ci-triggers-from-spec.mjs --check", "test:onboarding-spec": "node --test scripts/onboarding-from-spec.test.mjs", "check:play": "node scripts/play-from-spec.mjs --check", "test:play-spec": "node --test scripts/play-from-spec.test.mjs", diff --git a/apps/website/qa/t27-world-discovery.mjs b/apps/website/qa/t27-world-discovery.mjs index 353aecaefd..a7ca438d35 100644 --- a/apps/website/qa/t27-world-discovery.mjs +++ b/apps/website/qa/t27-world-discovery.mjs @@ -135,7 +135,11 @@ assert.ok(onAt>-1&&jobsAt>onAt,'website-checks has a top-level on: before jobs: const triggers=wc.slice(onAt,jobsAt); assert.match(triggers,/pull_request:/,'the trigger block is the one read'); assert.doesNotMatch(triggers,/paths:/,'website-checks runs on every PR; scope decides, not a trigger path filter'); -assert.match(wc,/\n gate:\n name: website gate\n needs: \[scope, checks\]\n if: always\(\)/,'website gate always reports, after scope and checks'); +// The gate needs every check group by name, so a group added to the workflow +// but not to this list reports "pass" while its checks never ran. +const GROUPS=['checks-pure','checks-lang-en','checks-lang-ru','checks-browser-a','checks-browser-b']; +assert.match(wc,new RegExp(`\\n gate:\\n name: website gate\\n needs: \\[scope, ${GROUPS.join(', ')}\\]\\n if: always\\(\\)`),'website gate always reports, after scope and every check group'); +for(const g of GROUPS)assert.match(wc,new RegExp(`\\n ${g}:\\n`),`check group ${g} exists`); assert.match(wc,/if: needs\.scope\.outputs\.site == 'true'/,'the heavy checks run when scope says the site changed'); assert.match(wc,/grep -Eq "\$SITE_PATHS" <<< "\$FILES"/,'scope reads the file list without a pipe into grep -q'); assert.doesNotMatch(wc,/\| grep -Eq "\$SITE_PATHS"/,'no pipe into grep -q: SIGPIPE under pipefail reads a big change as none'); diff --git a/apps/website/scripts/ci-triggers-from-spec.mjs b/apps/website/scripts/ci-triggers-from-spec.mjs new file mode 100644 index 0000000000..c6b2329018 --- /dev/null +++ b/apps/website/scripts/ci-triggers-from-spec.mjs @@ -0,0 +1,356 @@ +#!/usr/bin/env node +// ci-triggers-from-spec.mjs -- the `on:` blocks of five workflows, from `specs/ci/workflow_triggers.t27`. +// +// Reads the repo-root spec through the real compiler (`t27_compiler.wasm`), +// checks the constant schema, evaluates every `test` block of the spec, and +// rewrites the `on:` region (and only that region) of each workflow file the +// spec names in GENERATED. A workflow the spec does not name is never touched. +// +// Why this exists: four of the five filters below were parked hand edits for a +// week -- the intent was written down twice, once in a stash and once in an +// issue, and neither ran. A trigger block that lives as data in a spec has a +// gate (`check:ci-triggers`) that fails the PR when the committed YAML is not +// what the spec says, so the parked state cannot come back. +// +// Nothing here parses a `.t27` with a regular expression; constants and +// asserts come from the compiler's AST. The asserts are evaluated on purpose: +// `typecheck.ok` stays true for `assert 1 > 2`, so a spec whose own tests do +// not hold must not produce a green build. +// +// Run: node scripts/ci-triggers-from-spec.mjs (write .github/workflows/*) +// node scripts/ci-triggers-from-spec.mjs --check (fail if a managed block is stale) +// node scripts/ci-triggers-from-spec.mjs --json (print the constants as JSON) +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { CYRILLIC, SITE, checkSchema, compilerErrors, constsOf, loadCompiler, sha256, verdictOf } from './agents-from-specs.mjs' +import { runSpecTests } from './viewport-from-spec.mjs' + +const WASM = 'public/t27/t27_compiler.wasm' +// The spec and its outputs live at the repository root, outside apps/website: +// the workflows are the repository's, not the site's. The npm scripts stay in +// apps/website because the compiler wasm and the generator idiom already do. +const REPO = resolve(SITE, '../..') +export const SPEC = 'specs/ci/workflow_triggers.t27' +export const WORKFLOWS_DIR = '.github/workflows' +export const EXPECTED_MODULE = 'ci_workflow_triggers' +export const MANAGED_BY = '# Managed by apps/website/scripts/ci-triggers-from-spec.mjs from specs/ci/workflow_triggers.t27.' +const TOP_LEVEL = /^[A-Za-z_][A-Za-z0-9_-]*:( |$)/ + +// The constant schema is regular: every managed workflow carries the same +// seven required trigger facts, and may carry the optional ones. The prefix +// (BRAIN_CI for brain-ci.yml) is derived from the spec's own GENERATED list, +// so adding a workflow to the spec adds its schema here with no other edit. +export function schemaFor(files) { + const required = { GENERATED: 'arr', PROSE_PATHS_IGNORE: 'arr' } + const optional = {} + for (const file of files) { + const p = prefixOf(file) + Object.assign(required, { + [`${p}_ON_PUSH`]: 'bool', [`${p}_PUSH_BRANCHES`]: 'arr', + [`${p}_ON_PR`]: 'bool', [`${p}_IGNORES_PROSE`]: 'bool', + [`${p}_ON_DISPATCH`]: 'bool', [`${p}_SCHEDULED`]: 'bool', + [`${p}_NOTE_PUSH`]: 'arr', + }) + Object.assign(optional, { + [`${p}_PR_BRANCHES`]: 'arr', [`${p}_PATHS`]: 'arr', [`${p}_SCHEDULE`]: 'arr', + [`${p}_NOTE_PR`]: 'arr', [`${p}_NOTE_DISPATCH`]: 'arr', [`${p}_NOTE_TAIL`]: 'arr', + [`${p}_DISPATCH_INPUT_NAME`]: 'str', [`${p}_DISPATCH_INPUT_DESC`]: 'str', + [`${p}_DISPATCH_INPUT_OPTIONS`]: 'arr', [`${p}_DISPATCH_INPUT_DEFAULT`]: 'str', + }) + } + return { required, optional } +} + +export function prefixOf(file) { + return file.replace(/\.yml$/, '').toUpperCase().replace(/-/g, '_') +} + +const CRON = /^(\S+\s+){4}\S+$/ +const IDENT = /^[A-Za-z0-9_.*][A-Za-z0-9_./*-]*$/ + +// Semantic checks the schema cannot express: the regular table has to be a +// coherent trigger block, and the prose that rides along has to survive being +// emitted as a YAML comment. +export function semanticProblems(f, file) { + const p = [] + const push = (m) => p.push(`${file}: ${m}`) + if (!Array.isArray(f.GENERATED) || f.GENERATED.length === 0) push('GENERATED must name at least one workflow') + if (new Set(f.GENERATED).size !== f.GENERATED.length) push('GENERATED names a workflow twice') + for (const [i, g] of (f.GENERATED ?? []).entries()) { + if (!/^[a-z0-9-]+\.yml$/.test(g)) push(`GENERATED[${i}] ${g} is not a workflow file name`) + } + if (!f.PROSE_PATHS_IGNORE?.length) push('PROSE_PATHS_IGNORE must not be empty') + for (const [i, s] of (f.PROSE_PATHS_IGNORE ?? []).entries()) if (!IDENT.test(s)) push(`PROSE_PATHS_IGNORE[${i}] ${s} is not a path pattern this emitter can quote`) + + for (const file_ of f.GENERATED ?? []) { + const w = prefixOf(file_) + const where = (m) => push(`${file_}: ${m}`) + if (f[`${w}_ON_PUSH`] !== true) where('must answer pushes; a workflow that ignores its own builds is not this table\'s business') + if (!f[`${w}_PUSH_BRANCHES`]?.length) where('PUSH_BRANCHES must not be empty') + if (f[`${w}_ON_PR`] === true && !f[`${w}_PR_BRANCHES`]?.length) where('ON_PR is true and PR_BRANCHES is missing or empty') + if (f[`${w}_ON_PR`] !== true && f[`${w}_PR_BRANCHES`] !== undefined) where('PR_BRANCHES is set but ON_PR is false; absent means absent') + if (f[`${w}_IGNORES_PROSE`] === true && f[`${w}_PATHS`] !== undefined) where('IGNORES_PROSE and PATHS are both set; a trigger filters one way') + if (f[`${w}_IGNORES_PROSE`] !== true && !f[`${w}_PATHS`]?.length) where('neither IGNORES_PROSE nor PATHS is set; an unfiltered trigger is what the spec exists to prevent') + if (f[`${w}_SCHEDULED`] === true) { + if (!f[`${w}_SCHEDULE`]?.length) where('SCHEDULED is true and SCHEDULE is missing or empty') + for (const [i, c] of (f[`${w}_SCHEDULE`] ?? []).entries()) if (!CRON.test(c)) where(`SCHEDULE[${i}] ${c} is not a five-field cron expression`) + } else if (f[`${w}_SCHEDULE`] !== undefined) { + where('SCHEDULE is set but SCHEDULED is false; absent means absent') + } + const name = f[`${w}_DISPATCH_INPUT_NAME`] + if (name) { + for (const k of ['DISPATCH_INPUT_DESC', 'DISPATCH_INPUT_OPTIONS', 'DISPATCH_INPUT_DEFAULT']) { + if (f[`${w}_${k}`] === undefined) where(`DISPATCH_INPUT_NAME is set and ${k} is missing`) + } + const options = f[`${w}_DISPATCH_INPUT_OPTIONS`] ?? [] + if (!options.length) where('DISPATCH_INPUT_OPTIONS must not be empty') + if (!options.includes(f[`${w}_DISPATCH_INPUT_DEFAULT`])) where('DISPATCH_INPUT_DEFAULT is not one of DISPATCH_INPUT_OPTIONS') + } else { + for (const k of ['DISPATCH_INPUT_DESC', 'DISPATCH_INPUT_OPTIONS', 'DISPATCH_INPUT_DEFAULT']) { + if (f[`${w}_${k}`] !== undefined) where(`${k} is set without DISPATCH_INPUT_NAME; absent means absent`) + } + } + for (const key of Object.keys(f)) { + if (!/_NOTE_(PUSH|PR|DISPATCH|TAIL)$/.test(key)) continue + const note = f[key] + if (!Array.isArray(note)) continue + for (const [i, line] of note.entries()) { + if (typeof line !== 'string' || !line.trim()) where(`${key}[${i}] is empty; an empty comment line is a gap, not prose`) + if (line !== line.trim()) where(`${key}[${i}] has leading or trailing space`) + if (!/[A-Za-z]/.test(line)) where(`${key}[${i}] carries no letter; notes are comments read by people`) + } + } + for (const key of [`${w}_PUSH_BRANCHES`, `${w}_PR_BRANCHES`, `${w}_PATHS`]) { + for (const [i, v] of (f[key] ?? []).entries()) if (!IDENT.test(v)) where(`${key}[${i}] ${v} is not a pattern this emitter can quote`) + } + } + return [...new Set(p)] +} + +// Single-quote anything that is not a bare word, so `main` stays main and +// `feat/*` becomes 'feat/*'. One quoting rule, not one per file. +const quote = (v) => (/^[A-Za-z0-9_-]+$/.test(v) ? v : `'${v}'`) + +const comment = (indent, lines) => (lines ?? []).map((l) => `${indent}# ${l}`) + +function triggerPaths(kind, paths, indent) { + return [`${indent}${kind}:`, ...paths.map((p) => `${indent} - ${quote(p)}`)] +} + +// Deterministic: the same spec bytes give the same workflow bytes. +export function renderBlock(f, file) { + const w = prefixOf(file) + const lines = [ + MANAGED_BY, + '# Do not edit this block: edit the spec, then run npm --prefix apps/website run gen:ci-triggers.', + 'on:', + ] + if (f[`${w}_ON_PUSH`] === true) { + lines.push(' push:', ` branches: [${f[`${w}_PUSH_BRANCHES`].map(quote).join(', ')}]`) + lines.push(...comment(' ', f[`${w}_NOTE_PUSH`])) + if (f[`${w}_IGNORES_PROSE`] === true) lines.push(...triggerPaths('paths-ignore', f.PROSE_PATHS_IGNORE, ' ')) + else lines.push(...triggerPaths('paths', f[`${w}_PATHS`], ' ')) + } + if (f[`${w}_ON_PR`] === true) { + lines.push(' pull_request:', ` branches: [${f[`${w}_PR_BRANCHES`].map(quote).join(', ')}]`) + lines.push(...comment(' ', f[`${w}_NOTE_PR`])) + if (f[`${w}_IGNORES_PROSE`] === true) lines.push(...triggerPaths('paths-ignore', f.PROSE_PATHS_IGNORE, ' ')) + else lines.push(...triggerPaths('paths', f[`${w}_PATHS`], ' ')) + } + if (f[`${w}_ON_DISPATCH`] === true) { + lines.push(...comment(' ', f[`${w}_NOTE_DISPATCH`])) + lines.push(' workflow_dispatch:') + const name = f[`${w}_DISPATCH_INPUT_NAME`] + if (name) { + lines.push(' inputs:', ` ${name}:`, ` description: ${quote(f[`${w}_DISPATCH_INPUT_DESC`])}`, + ' required: true', ` default: ${quote(f[`${w}_DISPATCH_INPUT_DEFAULT`])}`, ' type: choice', + ' options:', ...f[`${w}_DISPATCH_INPUT_OPTIONS`].map((o) => ` - ${quote(o)}`)) + } + } + if (f[`${w}_SCHEDULED`] === true) { + lines.push(' schedule:', ...f[`${w}_SCHEDULE`].map((c) => ` - cron: ${quote(c)}`)) + } + // The tail note (why a schedule block is absent) rides right after the first + // branches line, not at the end of the block. The crons catalog + // (public/crons/manifest.json, gate check:crons-catalog) cites the + // no-schedule note of codegen.yml and brain-ci.yml BY LINE NUMBER and reads + // a window around it for the word cron; a header plus three paths lists + // would push an end-of-block note out of that window. citationProblem + // below enforces the same window here, so the two cannot drift apart. + const tail = comment(' ', f[`${w}_NOTE_TAIL`]) + if (tail.length > 0) { + const first = lines.findIndex((l) => /^ {4}branches: \[/.test(l)) + if (first < 0) lines.push(...comment(' ', f[`${w}_NOTE_TAIL`])) + else lines.splice(first + 1, 0, ...tail) + } + return lines.join('\n') +} + +// The catalog cites schedule-bearing lines of these workflows by number and +// checks lines [line-3, line+2) for the word `cron` -- the same near-window +// qa/crons-catalog-contract.mjs checks in CI. The generator owns the layout +// now, so it owns keeping those citations true: a layout change that drifts a +// citation fails here, at generation time, with the line numbers in hand. +export function citationProblem(text, file, citations) { + const lines = text.split('\n') + for (const c of citations) { + const near = lines.slice(Math.max(0, c.line - 3), c.line + 2).join('\n') + if (!near.includes('cron')) { + return `${file}: the crons catalog entry ${c.id} cites ${file}:${c.line}; lines ${Math.max(1, c.line - 2)}-${c.line + 2} no longer mention cron` + } + } + return null +} + +function catalogCitations() { + const manifest = JSON.parse(readFileSync(join(SITE, 'public/crons/manifest.json'), 'utf8')) + const byFile = new Map() + for (const entry of manifest.crons) { + const where = entry.where ?? {} + if (entry.kind !== 'github-actions' || !where.file?.startsWith('.github/workflows/') || !where.line) continue + // Citations resolve against the entry's own repository (the gate looks up + // ROOTS[entry.repo]); the bot repository's ci.yml:10 is not this ci.yml. + if ((entry.repo ?? '').replace('gHashTag/', '') !== 'trinity') continue + if (!byFile.has(where.file)) byFile.set(where.file, []) + byFile.get(where.file).push({ id: entry.id, line: where.line }) + } + return byFile +} + +// Replace the managed region: from the managed-by header if the generator has +// been run before, else from the bare `on:` line, through to (not including) +// the next top-level key. The blank lines that separated the old region from +// the next key are kept as they were, so a first run and every later run leave +// the file shaped the same way. +export function splice(text, block, file) { + const lines = text.split('\n') + const headerIdx = lines.findIndex((l) => l === MANAGED_BY) + const onIdx = lines.indexOf('on:') + if (onIdx < 0) throw new Error(`${file}: no top-level on: block to manage`) + if (headerIdx >= 0 && onIdx < headerIdx) throw new Error(`${file}: managed header appears after the on: block`) + const start = headerIdx >= 0 ? headerIdx : onIdx + // The region runs from the header (or on:) past on: to the next top-level + // key. Scanning from start would stop at on: itself -- a top-level key -- + // and claim the region holds only the header comments. + let end = onIdx + 1 + while (end < lines.length && !TOP_LEVEL.test(lines[end])) end++ + if (end >= lines.length) throw new Error(`${file}: the on: block runs to the end of the file; nothing follows it`) + let blanks = 0 + while (end - blanks - 1 > onIdx && lines[end - blanks - 1] === '') blanks++ + const out = [...lines.slice(0, start), ...block.split('\n'), ...Array(blanks).fill(''), ...lines.slice(end)] + return out.join('\n') +} + +const plain = (consts) => Object.fromEntries(Object.entries(consts).map(([k, v]) => [k, v.value])) + +export async function buildTriggers({ specText, analyze }) { + const problems = [] + const file = SPEC + const analysis = analyze(specText) + const verdict = verdictOf(analysis) + if (!verdict.typecheckOk || verdict.discarded > 0 || !verdict.hirOk) problems.push(`${file}: compiler verdict not clean (${JSON.stringify(verdict)})`) + problems.push(...compilerErrors(analysis).map((m) => `${file}: ${m}`)) + if (/[^\x00-\x7f]/.test(specText)) problems.push(`${file}: non-ASCII byte in the spec (L3)`) + if (CYRILLIC.test(specText)) problems.push(`${file}: Cyrillic in the spec (LANG-EN)`) + const moduleName = analysis.ast?.name ?? null + if (moduleName !== EXPECTED_MODULE) problems.push(`${file}: module must be ${EXPECTED_MODULE}, is ${moduleName}`) + + let consts = {} + try { consts = constsOf(analysis) } catch (e) { problems.push(`${file}: ${e.message}`) } + const f = plain(consts) + const { required, optional } = schemaFor(f.GENERATED ?? []) + problems.push(...checkSchema(consts, required, optional, file)) + if (problems.length === 0) { + problems.push(...semanticProblems(f, file)) + const tests = runSpecTests(analysis, f) + if (tests.tests === 0) problems.push(`${file}: no test block; the spec must test its own claims`) + problems.push(...tests.failures.map((m) => `${file}: test ${m}`)) + } + + const outputs = new Map() + if (problems.length === 0) { + const citations = catalogCitations() + for (const wf of f.GENERATED) { + const wfPath = join(REPO, WORKFLOWS_DIR, wf) + if (!existsSync(wfPath)) { problems.push(`${SPEC}: GENERATED names ${wf}, which is not in ${WORKFLOWS_DIR}`); continue } + const text = splice(readFileSync(wfPath, 'utf8'), renderBlock(f, wf), wf) + const cited = citations.get(`${WORKFLOWS_DIR}/${wf}`) ?? [] + const drift = citationProblem(text, wf, cited) + if (drift) problems.push(drift) + outputs.set(wf, text) + } + } + return { problems, fields: f, outputs, specSha: sha256(Buffer.from(specText, 'utf8')) } +} + +// The negative controls. A gate nobody has watched fail is not trusted on the +// day it matters, and this one rewrites CI triggers -- the wiring every other +// gate hangs from. All three run against the real spec and the real workflows, +// in memory, before anything is written or compared. +export async function selfTest({ specText, analyze, outputs }) { + const results = [] + const expect = (name, ok) => { results.push([name, ok]); if (!ok) console.error(`ci-triggers-from-spec: self-test ${name} FAILED`) } + + expect('splice refuses a workflow with no on: block', (() => { + try { splice('name: X\n\njobs:\n a:\n', 'on:\n', 'x.yml'); return false } catch { return true } + })()) + + expect('the stale check catches a dropped path', (() => { + const victim = outputs.get('ci.yml') + if (victim === undefined) return false + const tampered = victim.replace(" - 'docs/**'\n", '') + return tampered !== victim && victim.includes(" - 'docs/**'\n") + })()) + + const { problems } = await buildTriggers({ specText: specText.replace('pub const CI_ON_PR : bool = true;', ''), analyze }) + expect('a spec missing a constant is refused by name', problems.some((m) => m.includes('missing CI_ON_PR'))) + + const flipped = await buildTriggers({ specText: specText.replace('pub const KOSCHEI_PRODUCTION_ON_PR : bool = false;', 'pub const KOSCHEI_PRODUCTION_ON_PR : bool = true;'), analyze }) + expect('a spec whose own test no longer holds is refused', flipped.problems.some((m) => m.startsWith(`${SPEC}: test`))) + + expect('the crons-catalog citation window is enforced', (() => { + const quiet = Array.from({ length: 12 }, () => 'x').join('\n') + const bad = citationProblem(quiet, 'x.yml', [{ id: 'github-actions/trinity/x', line: 8 }]) + const good = citationProblem('a\nb\nschedule:\n - cron: x\n', 'x.yml', [{ id: 'github-actions/trinity/x', line: 4 }]) + const none = citationProblem(quiet, 'x.yml', []) + return bad !== null && bad.includes('crons catalog') && good === null && none === null + })()) + + return results +} + +async function main() { + const check = process.argv.includes('--check') + const json = process.argv.includes('--json') + const specPath = join(REPO, SPEC) + if (!existsSync(specPath)) { console.error(`ci-triggers-from-spec: ${SPEC} is missing`); process.exit(1) } + const wasmBytes = readFileSync(join(SITE, WASM)) + const analyze = await loadCompiler(wasmBytes) + const specText = readFileSync(specPath, 'utf8') + const out = await buildTriggers({ specText, analyze }) + if (out.problems.length) { + console.error(`ci-triggers-from-spec: ${out.problems.length} problem(s)`) + for (const p of out.problems) console.error(' ' + p) + process.exit(1) + } + const controls = await selfTest({ specText, analyze, outputs: out.outputs }) + if (controls.some(([, ok]) => !ok)) { console.error('ci-triggers-from-spec: self-test failed; nothing written, nothing compared'); process.exit(1) } + + if (json) { console.log(JSON.stringify({ specSha: out.specSha, ...out.fields })); return } + const names = [...out.outputs.keys()] + if (check) { + const stale = names.filter((wf) => readFileSync(join(REPO, WORKFLOWS_DIR, wf), 'utf8') !== out.outputs.get(wf)) + if (stale.length) { + console.error(`ci-triggers-from-spec --check: stale trigger block in ${stale.join(', ')}; run npm --prefix apps/website run gen:ci-triggers`) + process.exit(1) + } + } else { + for (const wf of names) writeFileSync(join(REPO, WORKFLOWS_DIR, wf), out.outputs.get(wf)) + } + console.log(`ci-triggers-from-spec: ${check ? 'up to date' : 'wrote'} the on: block of ${names.join(', ')}; spec sha256 ${out.specSha.slice(0, 16)}; self-test ${controls.length}/${controls.length} held`) +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((e) => { console.error(e); process.exit(1) }) +} diff --git a/specs/ci/workflow_triggers.t27 b/specs/ci/workflow_triggers.t27 new file mode 100644 index 0000000000..a9bede30ad --- /dev/null +++ b/specs/ci/workflow_triggers.t27 @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +; The `on:` blocks of five GitHub Actions workflows, as data. +; apps/website/scripts/ci-triggers-from-spec.mjs reads this spec through the +; real compiler and rewrites the `on:` region of each file named in GENERATED. +; A workflow not listed here is not managed: the generator never touches it. +; Why five: these are the builds that pay minutes on every push to main, and +; the four prose filters below existed as parked hand edits (branch +; docs/ci-paths-ignore-website) before they became constants here. The site +; keeps its own gate (website-checks.yml), which deliberately carries no path +; filter -- a filter there made the check absent on most PRs, so it could not +; be required (#1535 merged 70 s after it opened with no site check at all). +module ci_workflow_triggers; + +pub const GENERATED : [5]str = ["brain-ci.yml", "ci.yml", "codegen.yml", "koschei-production.yml", "reproduce.yml"]; + +; One list, four readers. The brain, the CI build, codegen and the production +; deploy all ignore the same three prose trees; reproduce instead names the +; build inputs it runs on. The list lives once so the four cannot drift. +pub const PROSE_PATHS_IGNORE : [3]str = ["apps/website/**", "docs/**", "**/*.md"]; + +pub const BRAIN_CI_ON_PUSH : bool = true; +pub const BRAIN_CI_PUSH_BRANCHES : [5]str = ["main", "v*", "feat/*", "fix/*", "refactor/*"]; +pub const BRAIN_CI_ON_PR : bool = true; +pub const BRAIN_CI_PR_BRANCHES : [2]str = ["main", "v*"]; +pub const BRAIN_CI_IGNORES_PROSE : bool = true; +pub const BRAIN_CI_ON_DISPATCH : bool = true; +pub const BRAIN_CI_SCHEDULED : bool = false; +pub const BRAIN_CI_NOTE_PUSH : [2]str = ["The brain is Zig in src/; a website or docs change cannot move its", "health score, and the site carries its own checks."]; +pub const BRAIN_CI_NOTE_TAIL : [2]str = ["No schedule: block. The Queen (Inngest app t27-queen) dispatches this workflow", "on '0 0 * * 0' UTC from the card specs/crons/trinity-brain-ci.t27 in gHashTag/t27."]; + +pub const CI_ON_PUSH : bool = true; +pub const CI_PUSH_BRANCHES : [1]str = ["main"]; +pub const CI_ON_PR : bool = true; +pub const CI_PR_BRANCHES : [1]str = ["main"]; +pub const CI_IGNORES_PROSE : bool = true; +pub const CI_ON_DISPATCH : bool = true; +pub const CI_SCHEDULED : bool = false; +pub const CI_NOTE_PUSH : [3]str = ["A website or docs change ships no binary: the site carries its own", "checks (website-checks.yml), and this build would test an unchanged", "Zig tree twice for prose."]; +pub const CI_NOTE_DISPATCH : [2]str = ["So this workflow's verdict can be refreshed behind a path filter;", "without it, its status is whatever its last accepted run concluded."]; + +pub const CODEGEN_ON_PUSH : bool = true; +pub const CODEGEN_PUSH_BRANCHES : [3]str = ["main", "ralph/dev-*", "vibee-v5-production"]; +pub const CODEGEN_ON_PR : bool = true; +pub const CODEGEN_PR_BRANCHES : [1]str = ["main"]; +pub const CODEGEN_IGNORES_PROSE : bool = true; +pub const CODEGEN_ON_DISPATCH : bool = true; +pub const CODEGEN_SCHEDULED : bool = false; +pub const CODEGEN_NOTE_PUSH : [2]str = ["Codegen consumes specs and the compiler in src/, not prose or the", "website; a docs-only merge regenerates byte-identical output."]; +pub const CODEGEN_NOTE_TAIL : [2]str = ["No schedule: block. The Queen (Inngest app t27-queen) dispatches this workflow", "on '0 0 * * *' UTC from the card specs/crons/trinity-codegen.t27 in gHashTag/t27."]; + +pub const KOSCHEI_PRODUCTION_ON_PUSH : bool = true; +pub const KOSCHEI_PRODUCTION_PUSH_BRANCHES : [1]str = ["main"]; +pub const KOSCHEI_PRODUCTION_ON_PR : bool = false; +pub const KOSCHEI_PRODUCTION_IGNORES_PROSE : bool = true; +pub const KOSCHEI_PRODUCTION_ON_DISPATCH : bool = true; +pub const KOSCHEI_PRODUCTION_SCHEDULED : bool = false; +pub const KOSCHEI_PRODUCTION_NOTE_PUSH : [2]str = ["A production deploy follows the code and images the swarm is built", "from, not prose: a website or docs merge ships nothing to KOSCHEI."]; +pub const KOSCHEI_PRODUCTION_DISPATCH_INPUT_NAME : str = "environment"; +pub const KOSCHEI_PRODUCTION_DISPATCH_INPUT_DESC : str = "Deployment environment"; +pub const KOSCHEI_PRODUCTION_DISPATCH_INPUT_OPTIONS : [2]str = ["staging", "production"]; +pub const KOSCHEI_PRODUCTION_DISPATCH_INPUT_DEFAULT : str = "production"; + +pub const REPRODUCE_ON_PUSH : bool = true; +pub const REPRODUCE_PUSH_BRANCHES : [1]str = ["main"]; +pub const REPRODUCE_ON_PR : bool = true; +pub const REPRODUCE_PR_BRANCHES : [1]str = ["main"]; +pub const REPRODUCE_IGNORES_PROSE : bool = false; +pub const REPRODUCE_PATHS : [10]str = ["build.zig", "build.zig.zon", "src/**", "external/**", "deploy/**/*.zig", "tools/reproduce.py", "tools/capabilities.py", "tools/contracts.py", "specs/reproduce/**", ".github/workflows/reproduce.yml"]; +pub const REPRODUCE_ON_DISPATCH : bool = true; +pub const REPRODUCE_SCHEDULED : bool = true; +pub const REPRODUCE_SCHEDULE : [1]str = ["23 4 * * 1"]; +pub const REPRODUCE_NOTE_PUSH : [3]str = ["The same inputs as the pull_request block: without them, every", "merge to main -- a blog post included -- rebuilt the headless profile", "from a cold cache to prove nothing new."]; + +test managed_workflows_are_the_five_paying_builds { + assert GENERATED[0] == "brain-ci.yml"; + assert GENERATED[1] == "ci.yml"; + assert GENERATED[2] == "codegen.yml"; + assert GENERATED[3] == "koschei-production.yml"; + assert GENERATED[4] == "reproduce.yml"; +} + +test prose_filter_is_one_list_not_four { + assert PROSE_PATHS_IGNORE[0] == "apps/website/**"; + assert PROSE_PATHS_IGNORE[1] == "docs/**"; + assert PROSE_PATHS_IGNORE[2] == "**/*.md"; + assert BRAIN_CI_IGNORES_PROSE == true; + assert CI_IGNORES_PROSE == true; + assert CODEGEN_IGNORES_PROSE == true; + assert KOSCHEI_PRODUCTION_IGNORES_PROSE == true; + assert REPRODUCE_IGNORES_PROSE == false; +} + +test reproduce_runs_on_build_inputs_only { + assert REPRODUCE_PATHS[0] == "build.zig"; + assert REPRODUCE_PATHS[4] == "deploy/**/*.zig"; + assert REPRODUCE_PATHS[9] == ".github/workflows/reproduce.yml"; + assert REPRODUCE_SCHEDULED == true; + assert REPRODUCE_SCHEDULE[0] == "23 4 * * 1"; + assert BRAIN_CI_SCHEDULED == false; + assert CI_SCHEDULED == false; + assert CODEGEN_SCHEDULED == false; + assert KOSCHEI_PRODUCTION_SCHEDULED == false; +} + +test only_koschei_dispatches_with_an_input { + assert BRAIN_CI_ON_DISPATCH == true; + assert CI_ON_DISPATCH == true; + assert CODEGEN_ON_DISPATCH == true; + assert KOSCHEI_PRODUCTION_ON_DISPATCH == true; + assert REPRODUCE_ON_DISPATCH == true; + assert KOSCHEI_PRODUCTION_ON_PR == false; + assert KOSCHEI_PRODUCTION_DISPATCH_INPUT_NAME == "environment"; + assert KOSCHEI_PRODUCTION_DISPATCH_INPUT_OPTIONS[0] == "staging"; + assert KOSCHEI_PRODUCTION_DISPATCH_INPUT_OPTIONS[1] == "production"; + assert KOSCHEI_PRODUCTION_DISPATCH_INPUT_DEFAULT == KOSCHEI_PRODUCTION_DISPATCH_INPUT_OPTIONS[1]; +} + +test every_managed_workflow_answers_pushes { + assert BRAIN_CI_ON_PUSH == true; + assert CI_ON_PUSH == true; + assert CODEGEN_ON_PUSH == true; + assert KOSCHEI_PRODUCTION_ON_PUSH == true; + assert REPRODUCE_ON_PUSH == true; + assert BRAIN_CI_ON_PR == true; + assert CI_ON_PR == true; + assert CODEGEN_ON_PR == true; + assert REPRODUCE_ON_PR == true; +}