CI #248
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |