Skip to content

CI

CI #248

Workflow file for this run

name: CI
# ─────────────────────────────────────────────────────────────────────────────
# gate ──▶ test ──▶ publish-allure
#
# gate Answers one question — should this run at all? The nightly
# schedule fires twice and this drops the firing that is not
# 05:00 in Israel. Everything else waits on its answer.
#
# test Every push and every PR. Typecheck, lint and format first, then
# Python, unit/API/contract, component and E2E workers run in
# parallel on the same prepared runner. The runner collects every
# result even when one worker fails, so Allure remains complete.
#
# publish-allure main only. Pushes the report to its own repository, so test
# results keep their own URL and never land on the site.
#
# Publishing the site is not here. It moved to deploy-vercel.yml when the site
# moved off GitHub Pages, which is why this workflow no longer asks for
# `pages: write` anywhere.
#
# Two rules make the reports trustworthy:
# · every layer carries `if: !cancelled()`, so one red layer never hides the
# rest — the report covers the whole run or it is not worth reading;
# · publishing does not depend on the tests passing, because a report you
# cannot open when the build is red is missing exactly when it is needed.
# ─────────────────────────────────────────────────────────────────────────────
# Runs on every push and pull request, plus a nightly check at 05:00 Israel
# time. GitHub cron only understands UTC and Israel switches between UTC+2 and
# UTC+3, so both candidate hours are scheduled and the `gate` job drops the one
# that is not actually 05:00 in Asia/Jerusalem.
on:
push:
branches: ['**']
pull_request:
schedule:
- cron: '0 2 * * *' # 05:00 IDT (UTC+3, summer)
- cron: '0 3 * * *' # 05:00 IST (UTC+2, winter)
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
NODE_VERSION: '24'
PNPM_VERSION: '11.20.0'
jobs:
gate:
name: Should this run?
runs-on: ubuntu-latest
outputs:
run: ${{ steps.check.outputs.run }}
steps:
- id: check
run: |
if [ "${{ github.event_name }}" != "schedule" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
hour=$(TZ=Asia/Jerusalem date +%H)
echo "Asia/Jerusalem hour is $hour"
if [ "$hour" = "05" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
echo "Not 05:00 in Israel — this is the off-season cron, skipping." >> "$GITHUB_STEP_SUMMARY"
fi
test:
name: Tests
needs: gate
if: needs.gate.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/setup
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
# First thing in the summary, before any report, because it is the thing
# someone reaches for when a step name means nothing to them. It runs
# early and unconditionally: the page is worth linking whether the run is
# green or red, and it does not depend on anything the run produces.
#
# The site lives on Vercel now, and a deployment URL is not derivable from
# anything this workflow can see — a production alias is a domain someone
# chose. So it comes from the repository variable, and when that is unset
# the summary says how to set it rather than linking somewhere wrong.
- name: Link the architecture page in the run summary
if: ${{ !cancelled() }}
env:
SITE_ORIGIN: ${{ vars.VERCEL_SITE_ORIGIN }}
run: |
set -uo pipefail
{
echo "## 🏛️ How this repository is put together"
echo ""
if [ -n "$SITE_ORIGIN" ]; then
url="${SITE_ORIGIN%/}/architecture.html"
echo "**➡️ [Architecture](${url})** — runtime shape, the AI layer, trust boundaries, the test strategy."
echo ""
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "_Republished by \`deploy-vercel.yml\` on this push._"
else
echo "_Published from \`main\`, so it may not yet describe this branch._"
fi
else
echo "\`architecture.html\` ships with the site — runtime shape, the AI layer, trust boundaries, the test strategy."
echo ""
echo "_Set the repository variable \`VERCEL_SITE_ORIGIN\` to the deployed origin to link it here._"
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Resolve the Playwright version
id: playwright
run: |
version=$(pnpm --filter @workspace/tests exec playwright --version | awk '{print $2}')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Restore the browser download
id: browser-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}
- name: Install Chromium
run: pnpm --filter @workspace/tests exec playwright install --with-deps chromium
# The exclusions this step used to carry — api-server and scripts, on the
# grounds that neither compiled — are gone: both compile clean now, so the
# gate is the plain script again, which is what the note asked for.
- name: Typecheck
run: pnpm run typecheck
# The two gates that used to exist only in package.json. Without them the
# repository drifted exactly where it was unwatched: typecheck stayed
# clean because this job enforced it, while lint reached 32 errors and
# 590 files fell off the Prettier config. A one-day-old `style: run
# Prettier over the sources` commit had already been undone by the time
# they were added, which is why the sweep alone was not the fix.
- name: Lint
if: ${{ !cancelled() }}
run: pnpm run lint
- name: Format
if: ${{ !cancelled() }}
run: pnpm run format:check
# `run-all-tests.sh` starts five isolated workers: TypeScript test
# typecheck, Python fixtures, unit/API/contract, component, and E2E/deck.
# Each worker writes a separate log and report directory; the script waits
# for all of them and returns non-zero if any failed. This keeps the full
# failure picture while cutting out the serial wait between layers.
- name: Run all test suites in parallel
if: ${{ !cancelled() }}
run: ./run-all-tests.sh
- name: Publish test history to Prometheus
if: ${{ !cancelled() }}
env:
PUSHGATEWAY_URL: ${{ secrets.PUSHGATEWAY_URL }}
PUSHGATEWAY_USERNAME: ${{ secrets.PUSHGATEWAY_USERNAME }}
PUSHGATEWAY_PASSWORD: ${{ secrets.PUSHGATEWAY_PASSWORD }}
run: pnpm --filter @workspace/api-server run publish:test-metrics
- name: Link Grafana in the run summary
if: ${{ !cancelled() }}
env:
GRAFANA_URL: ${{ vars.GRAFANA_URL }}
run: |
if [ -n "$GRAFANA_URL" ]; then
dashboard="${GRAFANA_URL%/}/d/academy-overview/academy-servers-and-test-history"
{
echo "## 📈 Runtime and test history"
echo ""
echo "**➡️ [Open the Grafana dashboard](${dashboard})**"
} >> "$GITHUB_STEP_SUMMARY"
else
{
echo "## 📈 Runtime and test history"
echo ""
echo "Set the public repository variable \`GRAFANA_URL\` to show the deployed dashboard link here."
} >> "$GITHUB_STEP_SUMMARY"
fi
# Every suite above wrote into the shared repo-root `allure-results`, so a
# single generate covers the whole run.
#
# Two formats, because the two delivery channels want different things.
# The multi-file report is what gets published: it is an ordinary static
# site of many small files, which is what a web server serves well. The
# single-file build inlines the whole report into one `index.html` so it
# can be downloaded and opened without a server — convenient to hand
# someone, and hopeless to publish.
#
# They were the same thing until that single file passed roughly 6 MB and
# GitHub's Pages build began failing on it outright: three runs in a row
# errored, the last success having been at 5.8 MB. Test output only grows,
# so the published copy is the one that must not be a single huge file.
- name: Generate the Allure report (published, multi-file)
if: ${{ !cancelled() }}
run: pnpm run report:allure
- name: Generate the Allure report (downloadable, single file)
if: ${{ !cancelled() }}
run: pnpm run report:allure:single
# The published copy, consumed by the `publish-allure` job below.
- name: Upload the Allure report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: allure-report
path: allure-report
retention-days: 14
if-no-files-found: warn
# The open-it-locally copy. Download, unzip, open `index.html` — no server.
- name: Upload the single-file Allure report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: allure-report-single-file
path: allure-report-single
retention-days: 14
if-no-files-found: warn
- name: Upload the Playwright report
id: pw-report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
tests/playwright-report
playwright-report
test-results
retention-days: 7
if-no-files-found: ignore
# Link the Playwright HTML report from the run summary. It ships as a
# downloadable artifact (GitHub does not host artifact HTML), so the link
# goes to the artifact; unzip and open index.html, or `npx playwright
# show-report <dir>`.
- name: Link the Playwright HTML report in the run summary
if: ${{ !cancelled() && steps.pw-report.outputs.artifact-url != '' }}
run: |
{
echo "## 🎭 Playwright HTML report"
echo ""
echo "**➡️ [Download the playwright-report artifact](${{ steps.pw-report.outputs.artifact-url }})**"
echo ""
echo "_Unzip and open \`index.html\` (or \`npx playwright show-report <dir>\`)._"
} >> "$GITHUB_STEP_SUMMARY"
# Raw Allure results from all four layers land in one directory; the HTML
# report is generated from them with `pnpm run report:allure`.
- name: Upload the Allure results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: allure-results
path: allure-results
retention-days: 7
if-no-files-found: ignore
publish-allure:
name: Publish the Allure report
# Publishes the report to a SEPARATE repository's gh-pages branch, so the
# test results get their own Pages URL and never touch the portfolio site.
# Runs on a main push even when the test job failed — a red run is exactly
# when someone needs to read the report.
#
# Requires two one-time setup items in this repo's settings:
# • Secret ALLURE_PAGES_TOKEN — a token (classic PAT with `repo`, or a
# fine-grained token with Contents: write on the target repo) that can
# push to the reports repository.
# • The target repo itself, with its deployment workflow on `main` and
# Pages set to "GitHub Actions". Generated report files live on
# `gh-pages`, so publishing cannot delete the deployment workflow.
# Defaults to <owner>/allure-report-learn-practice-deploy; override with
# the repository variable ALLURE_PAGES_REPO if you rename it.
needs: [gate, test]
if: ${{ !cancelled() && needs.gate.outputs.run == 'true' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
# Lift the secret to a job env so a step `if` can test whether it is set —
# secrets cannot be referenced in `if` directly, env can.
env:
ALLURE_PAGES_TOKEN: ${{ secrets.ALLURE_PAGES_TOKEN }}
steps:
- name: Download the Allure report
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: allure-report
path: allure-report
# Diagnostic: always report whether the workflow can see the secret, never
# its value (only the character count), so the run summary makes it obvious
# when the secret is missing or pasted empty.
- name: Report ALLURE_PAGES_TOKEN status
run: |
if [ -n "$ALLURE_PAGES_TOKEN" ]; then
echo "✅ ALLURE_PAGES_TOKEN is **set** (${#ALLURE_PAGES_TOKEN} chars) — the Allure publish will run." >> "$GITHUB_STEP_SUMMARY"
else
echo "❌ ALLURE_PAGES_TOKEN is **empty / not set** — the Allure publish is skipped. Add it under Settings → Secrets and variables → Actions → Repository secrets in this repo." >> "$GITHUB_STEP_SUMMARY"
fi
# Publish only when the token exists and the test job actually produced a
# report, so a missing secret or a run that died early skips cleanly
# instead of failing red.
- name: Publish to the reports repo (gh-pages)
if: ${{ env.ALLURE_PAGES_TOKEN != '' && hashFiles('allure-report/index.html') != '' }}
uses: peaceiris/actions-gh-pages@v4
with:
personal_token: ${{ env.ALLURE_PAGES_TOKEN }}
external_repository: ${{ vars.ALLURE_PAGES_REPO || format('{0}/allure-report-learn-practice-deploy', github.repository_owner) }}
publish_branch: gh-pages
publish_dir: ./allure-report
keep_files: false
user_name: 'github-actions[bot]'
user_email: 'github-actions[bot]@users.noreply.github.com'
commit_message: 'Allure report from ${{ github.repository }}@${{ github.sha }}'
# The reports repository keeps its Pages workflow on `main`, while the
# generated site is force-pushed to `gh-pages`. Explicitly notify that
# workflow after each publish so it can package and deploy the new report.
- name: Deploy the published report
if: ${{ env.ALLURE_PAGES_TOKEN != '' && hashFiles('allure-report/index.html') != '' }}
env:
GH_TOKEN: ${{ env.ALLURE_PAGES_TOKEN }}
REPORT_REPO: ${{ vars.ALLURE_PAGES_REPO || format('{0}/allure-report-learn-practice-deploy', github.repository_owner) }}
run: gh api --method POST "repos/${REPORT_REPO}/dispatches" -f event_type=report-published
# Put the report's OWN Pages URL in the run summary, so the correct link is
# one click away here — the portfolio's Pages URL shown on the "Publish to
# GitHub Pages" job is a different site and is easy to click by mistake.
- name: Link the published report in the run summary
if: ${{ env.ALLURE_PAGES_TOKEN != '' && hashFiles('allure-report/index.html') != '' }}
env:
REPORT_REPO: ${{ vars.ALLURE_PAGES_REPO || format('{0}/allure-report-learn-practice-deploy', github.repository_owner) }}
run: |
owner="${REPORT_REPO%%/*}"
name="${REPORT_REPO##*/}"
url="https://${owner}.github.io/${name}/"
{
echo "## 📊 Allure test report"
echo ""
echo "**➡️ [${url}](${url})**"
echo ""
echo "_Published to the \`${REPORT_REPO}\` repo — a separate site from the portfolio._"
} >> "$GITHUB_STEP_SUMMARY"
echo "Allure report URL: $url"