From 9a9139a22cb86ef1e7367a76dfe269d00720485f Mon Sep 17 00:00:00 2001 From: Michael Chen Date: Mon, 2 Nov 2026 16:41:24 +0800 Subject: [PATCH 1/2] feat: verify one theme package across supported Gitea versions --- .github/workflows/release.yml | 127 ++++++++--- ARCHITECTURE.md | 28 ++- README.md | 56 ++++- build/build.mjs | 27 ++- build/fetch-gitea.mjs | 33 +++ build/gitea-versions.json | 4 + build/gitea-versions.mjs | 28 +++ build/package.mjs | 7 +- build/page-scope-check.mjs | 82 ++++--- build/tests/compatibility.test.mjs | 102 +++++++++ package.json | 5 +- .../actions-packages-projects/scopes.json | 2 + src/pages/people/scopes.json | 2 + src/pages/settings-admin/scopes.json | 5 + templates/base/head_navbar.tmpl | 8 +- templates/custom/gh_head_navbar.tmpl | 4 +- tools/compat/README.md | 36 ++++ tools/compat/check.mjs | 201 ++++++++++++++++++ 18 files changed, 663 insertions(+), 94 deletions(-) create mode 100644 build/fetch-gitea.mjs create mode 100644 build/gitea-versions.json create mode 100644 build/gitea-versions.mjs create mode 100644 build/tests/compatibility.test.mjs create mode 100644 tools/compat/README.md create mode 100644 tools/compat/check.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21b0a16..cc6722c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,5 @@ -name: Build & release +name: Build, compatibility & release -# Every push / PR builds the theme and uploads a drop-in package as a workflow artifact. -# Pushing a tag v* also creates a GitHub Release with the .zip / .tar.gz attached. on: push: branches: [main] @@ -10,55 +8,126 @@ on: workflow_dispatch: permissions: - contents: write + contents: read jobs: build: runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.versions.outputs.matrix }} env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 with: node-version: 24 cache: npm - - run: npm ci - - - name: Lint (no literal colors, tokens only, selector ownership) - run: npm run lint - - - name: Build theme files - # Release tags must meet the 300 KB budget; branch builds only report it. - run: | - if [[ "$GITHUB_REF" == refs/tags/v* ]]; then - node build/build.mjs --strict - else - node build/build.mjs --budget-warn - fi - - - name: Package for Gitea CUSTOM_PATH - run: node build/package.mjs - + - id: versions + name: Read the shared Gitea version matrix + run: echo "matrix=$(node build/gitea-versions.mjs)" >> "$GITHUB_OUTPUT" + - run: npm test + - uses: actions/cache@v6 + with: + path: .cache/gitea-sources + key: gitea-sources-v1-${{ hashFiles('build/gitea-versions.json') }} + - name: Fetch all supported upstream source trees + run: npm run upstream + - name: Lint and check page scopes on every supported version + run: npm run lint -- --strict + - name: Build once (strict checks and 340 KB budget) + run: npm run build -- --strict + - name: Package once for the entire matrix + run: npm run package - name: Summary run: | - echo "### Theme files" >> "$GITHUB_STEP_SUMMARY" - node -e "const r=require('./dist/build-report.json');for(const [n,t] of Object.entries(r.themes))console.log('- theme-'+n+'.css: '+t.kb+' KB'+(t.overBudget?' (over 300 KB budget)':''))" >> "$GITHUB_STEP_SUMMARY" - + node --input-type=module <<'JS' >> "$GITHUB_STEP_SUMMARY" + import fs from 'node:fs'; + const r = JSON.parse(fs.readFileSync('dist/build-report.json')); + console.log('### One package for Gitea ' + r.giteaVersions.join(', ')); + for (const [name, theme] of Object.entries(r.themes)) console.log(`- theme-${name}.css: ${theme.kb} KB`); + JS - uses: actions/upload-artifact@v7 with: name: gitea-theme-github path: | - dist/gitea-theme-github-*.zip dist/gitea-theme-github-*.tar.gz + dist/gitea-theme-github-*.zip dist/theme-github-*.css + dist/build-report.json if-no-files-found: error - - name: GitHub Release - if: startsWith(github.ref, 'refs/tags/v') - uses: softprops/action-gh-release@v3 + compatibility: + needs: build + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.build.outputs.matrix) }} + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + GITEA_VERSION: ${{ matrix.version }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + - run: npm ci + - uses: actions/download-artifact@v8 + with: + name: gitea-theme-github + path: dist + - run: npx playwright install --with-deps chromium + - name: Test the unchanged package on Gitea ${{ matrix.version }} + run: | + packages=(dist/gitea-theme-github-*.tar.gz) + test "${#packages[@]}" -eq 1 + npm run compat -- --version "$GITEA_VERSION" --package "${packages[0]}" --out "shots/compat/$GITEA_VERSION" + - uses: actions/upload-artifact@v7 + if: always() + with: + name: compatibility-${{ matrix.version }} + path: shots/compat/${{ matrix.version }}/ + if-no-files-found: warn + + release: + needs: [build, compatibility] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-24.04 + permissions: + contents: write + env: + GITEA_MATRIX: ${{ needs.build.outputs.matrix }} + steps: + - uses: actions/setup-node@v7 + with: + node-version: 24 + - uses: actions/download-artifact@v8 + with: + name: gitea-theme-github + path: dist + - uses: actions/download-artifact@v8 + with: + pattern: compatibility-* + path: compatibility-reports + - name: Verify every matrix result tested exactly this archive + run: | + node --input-type=module <<'JS' + import assert from 'node:assert/strict'; + import fs from 'node:fs'; + import crypto from 'node:crypto'; + const archives = fs.readdirSync('dist').filter(name => name.endsWith('.tar.gz')); + assert.equal(archives.length, 1); + const sha = crypto.createHash('sha256').update(fs.readFileSync(`dist/${archives[0]}`)).digest('hex'); + for (const version of JSON.parse(process.env.GITEA_MATRIX).version) { + const report = JSON.parse(fs.readFileSync(`compatibility-reports/compatibility-${version}/report.json`)); + assert.equal(report.version, version); + assert.equal(report.ok, true); + assert.equal(report.packageSha256, sha); + } + JS + - uses: softprops/action-gh-release@v3 with: files: | dist/gitea-theme-github-*.zip diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index da54c6e..f168711 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -30,7 +30,7 @@ Environment facts: `docs/CONTEXT.md`. Gitea source research (with file:line cita | `@primer/primitives` | **11.10.0** | all tokens (`build/gen-tokens.mjs` → `src/tokens/generated/`) | | `@primer/octicons` | **19.38.0** | icon source (Gitea 1.27.3 bundles 19.28.1) | | `@primer/css` | **22.3.2** | reference only for component specs (not shipped) | -| Gitea | **1.27.3** and **28.1.0** | sources at `../gitea-src-1.27.3`, `../gitea-src-28.1.0`; one package for both (§7 "Two Gitea versions") | +| Gitea | **1.27.3**, **28.0.0**, **28.1.0**, **28.2.0**, **28.3.0** | CI/source matrix: `build/gitea-versions.json`; one package (§7 "Two Gitea series") | Dependency versions are pinned in `package.json` and `package-lock.json`. @@ -193,10 +193,12 @@ keep the non-GitHub output byte-identical. Install these files into `CUSTOM_PATH/templates/` and reload templates as described in README.md's upgrade instructions. `npm run deploy` checks for template drift but does not install template overrides. -**Two Gitea versions (1.27.x and 28.x, one package).** Each template that differs between 1.27.3 and 28.1.0 starts with +**Two Gitea series (1.27.x and 28.x, one package).** Each template that differs between 1.27.3 and 28.1.0 starts with `{{$gitea1 := StringUtils.HasPrefix AppVer "1."}}` (no output) and branches only where upstream differs, so a non-github theme gets each version's upstream bytes (checked: anonymous HTML of all routes with vs. without the templates, both -versions, differs only by the documented `hidden` "N Commits" link). Two rules learned the hard way: +versions, differs only by the documented `hidden` "N Commits" link). The native navbar also preserves the old avatar markup on 28.0/28.1 and the flex markup introduced in 28.2; +the GitHub navbar uses a flex layout supported by every target. The disposable compatibility check compares each +version's native forced-password navbar before and after loading the overrides. Two rules learned the hard way: - Fields and methods are resolved at execution, so an untaken branch may name `.Repository.CanEnableEditor` (1.27) or `CanContentChange` (28); use `and`/`or` (lazy since Go 1.18) or `{{if $gitea1}}` around them. - `{{template "x"}}` is checked by html/template's escaper even in a branch that never runs: an undefined name breaks the @@ -241,7 +243,9 @@ column colors, repo units, federation, wiki clone box, etc.) get the **closest P use), −100 KB per file. Loop 2 also merges Primer tokens whose definitions are identical in every scheme into one short name (91 tokens; only tokens no folder re-declares, which are all resolved on :root; `--no-dedupe`). Gitea's own names (`--color-*`, `--is-dark-theme`, anything in gitea-map.css / scheme-*.css / folder-local names) and any Primer name mentioned in Gitea's web_src/templates or our templates are - never renamed. `dist/theme-github-*.src.css` keeps the real names; `dist/varmap.json` maps short → Primer name. + never renamed. The build scans the union of every matrix version's templates and frontend sources, also retaining + tokens used only by upstream code. Missing source trees are an error; fetch them with `npm run upstream`. + `dist/theme-github-*.src.css` keeps the real names; `dist/varmap.json` maps short → Primer name. So in DevTools a computed `--fgColor-muted` reads as `--…` — look it up in varmap.json, or build with `--no-rename` (identical rendering: pixel-diffed on 8 routes × 2 schemes, only the footer's server-timing text differs). - Prefix nesting (loop 1, integrator; `build/nest.mjs`): as the last step, in the minified `dist/theme-github-*.css` only, @@ -255,10 +259,12 @@ column colors, repo units, federation, wiki clone box, etc.) get the **closest P (TARGETS): Chrome/Edge 112+ (target 120), Safari 16.5+, Firefox 117+ — all evergreen since 2023. `--no-nest` builds the flat file for comparison. `*.src.css` stays flat, so coverage and the lint are unaffected; in DevTools nested rules show as `& …` under their parent. -- Page-scope check (FG2-105, `build/page-scope-check.mjs`): page-folder selector scopes vs every Gitea template's - `.page-content` classes (+ live route classes cached in `shots/page-classes.json`); each page folder declares its intended +- Page-scope check (FG2-105, `build/page-scope-check.mjs`): page-folder selector scopes vs each matrix version's Gitea templates independently + (`.page-content` classes, plus optional instance-specific live caches under `.cache/page-scope//`); each page folder declares its intended templates in `src/pages//scopes.json`; an unintended match is a LEAK (warning in `npm run lint` and the build, - error under `--strict`). Reports: `docs/page-scope-report.{md,json}` (generated locally, ignored by Git). `--live` refetches, `--probe` counts matched elements. + error under `--strict`). Aggregate reports: `docs/page-scope-report.{md,json}`; version reports: `.cache/page-scope//report.{md,json}` (all ignored by Git). + `--version --live` refetches a matching instance; `--version --probe` verifies the instance, refetches + and counts matched elements. Static checks use upstream + tracked overrides; `--live`/`--probe` include live custom overrides. - `npm run deploy` — build, then atomically copy the three files to `CUSTOM_PATH/public/assets/css/`, copy icons, bump `github_revision` in `head_style.tmpl`, `gitea manager reload-templates`, then **fetch each file back from Gitea and compare SHA-256** (the deploy fails if Gitea serves different bytes). Reports `restartRequired` when theme @@ -294,6 +300,14 @@ Mona, Hubot, Copilot marks or other trademarks; the Gitea logo stays. Fonts: sys ## 13. Verification loop +- `npm test` — build regressions: reserve names across upstream versions, retain upstream-only tokens, reject missing + source trees, detect version-specific page leaks, ignore unrelated live caches, and keep package metadata in sync. +- `npm run compat -- --version --package ` — fresh disposable Docker database and users; + original native navbar vs package, forced-password layout, all overridden page structures, light/dark, desktop/mobile, + fixed themes, Mermaid, clipboard, JavaScript errors and CSS layering. The same tarball is used for every CI target. + GitHub Release publication waits for the matrix and validates its report checksums. The older seeded tools below + remain useful for deeper visual reviews and write-heavy functional tests. + - `tools/seed/seed.mjs` — idempotent seed via API (admin token): migrated public GitHub repos (same pages exist on github.com), users, org + teams, a playground repo with a markdown torture-test README, large multi-file PR, Actions runs, a package, releases, wiki. Manifest: `docs/seed-manifest.json`. Pre-seed baseline: `docs/baseline-pre-seed.json`. diff --git a/README.md b/README.md index c3d08e5..28fa746 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Build & release](https://github.com/mechiland/gitea-theme-github/actions/workflows/release.yml/badge.svg)](https://github.com/mechiland/gitea-theme-github/actions/workflows/release.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -A GitHub look-and-feel theme for **Gitea 1.27.x and 28.x** (tested on 1.27.3 and 28.1.0; one package for both), in light, dark and auto (follows the OS color scheme). +A GitHub look-and-feel theme for **Gitea 1.27.x and 28.x** (tested on 1.27.3, 28.0.0, 28.1.0, 28.2.0 and 28.3.0; one package for all), in light, dark and auto (follows the OS color scheme). It is built from GitHub's own open-source design system, not by recolouring Gitea: - every color, size, radius, shadow, type and motion value comes from [Primer Primitives](https://primer.style/product/primitives/) (`@primer/primitives` 11.10.0); @@ -57,6 +57,7 @@ public/assets/css/theme-github-{auto,light,dark}.css the themes (one self-cont public/assets/img/svg/*.svg Octicons 19.38 upgrade + Octicon replacements for non-Octicon icons templates/** GitHub-only template blocks (other themes render Gitea's upstream HTML; repo/view_list.tmpl adds one link that stays hidden for them) +COMPATIBILITY.json supported series, CI versions and build revision INSTALL.md, LICENSE, NOTICE ``` @@ -90,7 +91,7 @@ With the Docker image, you can set the same values with environment variables in ```yaml services: server: - image: docker.gitea.com/gitea:28.1.0 # or 1.27.x + image: docker.gitea.com/gitea:28.3.0 # or 1.27.3 / another supported 28.x version environment: - GITEA__ui__THEMES=gitea-auto,gitea-light,gitea-dark,github-auto,github-light,github-dark - GITEA__ui__DEFAULT_THEME=github-auto @@ -136,8 +137,10 @@ Extracting never deletes files. If the release notes say a template was removed, ```bash git clone https://github.com/mechiland/gitea-theme-github && cd gitea-theme-github npm ci +npm run upstream # cache templates + frontend sources for every version in build/gitea-versions.json +npm test # build/packaging regression tests npm run lint # no literal colors outside the token layer, type/radius/shadow from tokens, selector ownership -npm run build # → dist/theme-github-{auto,light,dark}.css (budget: ≤ 340 KB each, minified) +npm run build -- --strict # → dist/theme-github-{auto,light,dark}.css (budget: ≤ 340 KB each, minified) npm run package # → dist/gitea-theme-github--gitea1.27-28.{zip,tar.gz} (the drop-in package above) ``` @@ -158,27 +161,58 @@ Captures and reports are local artifacts: `shots/`, `docs/reference/`, `docs/pag `dist/` are ignored by Git. The screenshot tool recreates reference captures; lint and build recreate the scope reports. `docs/seed-manifest.json` and `docs/baseline-pre-seed.json` stay tracked because the development tools use them. -### CI / releases +### Compatibility and CI / releases + +One package supports both **Gitea 1.27.x and 28.x**. The CI matrix is defined once in +[`build/gitea-versions.json`](build/gitea-versions.json): **1.27.3, 28.0.0, 28.1.0, 28.2.0 and 28.3.0**. +It is also used for upstream source checks and the package's `INSTALL.md` and `COMPATIBILITY.json`. +Older 1.27 patch releases are not separate CI targets. A future Gitea release is added to this matrix after review; +existing targets stay in the matrix when support is extended. [`.github/workflows/release.yml`](.github/workflows/release.yml) runs on every push and pull request: -1. lint; -2. build; -3. package; -4. upload the package as a workflow artifact. +1. run build regression tests, fetch all matrix versions' upstream sources, and check selectors against each version; +2. build with strict checks and the **340 KB** budget, then package **once**; +3. install that unchanged archive in a fresh container for every matrix version; +4. check native and GitHub navigation, forced password changes, repository/file/blame/wiki/issue/PR pages, + theme switching, Mermaid and code copy, in light/dark at 1440/390px; +5. retain screenshots, logs and a JSON report recording the archive SHA-256 for each version. + +A `v*` tag publishes a GitHub Release **only after every matrix job succeeds**, using the same archives tested above. +The release job also verifies that every version's report names the same archive SHA-256. CI failure prevents publication. +The `.zip` and `.tar.gz` remain named `gitea-theme-github--gitea1.27-28.*`. + +To run the same container check locally (requires Docker and Playwright Chromium): + +```bash +npx playwright install chromium +npm run package +npm run compat -- --version 28.3.0 --package dist/gitea-theme-github--gitea1.27-28.tar.gz +``` -Pushing a tag `v*` builds with `--strict`, which enforces the 340 KB budget, and publishes a GitHub Release with the `.zip` and `.tar.gz` attached: +Use each version from the matrix to run the complete check. The tool creates disposable data and removes its +container afterwards; it does not use your existing Gitea instance. Reports go to `shots/compat//`. +See [tools/compat/README.md](tools/compat/README.md) for the checks and limitations. + +Source downloads are cached in `.cache/gitea-sources//`; existing read-only `../gitea-src-` +checkouts can also be used. Set `GITEA_SOURCE_ROOT` to a directory containing all matrix version directories to +use another cache. Missing sources fail the build rather than silently reducing its compatibility checks. +Page-scope reports and live caches are separated by version under `.cache/page-scope/`; live caches also include +the instance URL and are checked against its running version: ```bash -npm version patch && git push --follow-tags +node build/page-scope-check.mjs --version 28.3.0 --strict +GITEA_URL=http://localhost:3001 node build/page-scope-check.mjs --version 28.3.0 --live ``` +The live command needs an authenticated shoot-tool session for that instance; after `npm run upstream`, normal build and source checks need no network access. Set `GITEA_STORAGE_STATE` to select a browser storage-state file for a live check. + ## Contributing Bug reports, visual fixes and documentation improvements are welcome. Changes must remain compatible with both **Gitea 1.27.x and 28.x**. - **Report a bug:** open an issue with your Gitea and browser versions, steps to reproduce, and screenshots when relevant. - **Make a change:** read [ARCHITECTURE.md](ARCHITECTURE.md), keep changes focused, and edit `src/` or `templates/` rather than generated files in `dist/`. Reuse the existing Primer tokens. -- **Submit a PR:** run `npm run lint` and `npm run build`, and verify changes on both supported Gitea versions. For visual changes, check light and dark themes and include before/after screenshots. +- **Submit a PR:** run `npm test`, `npm run lint -- --strict` and `npm run build -- --strict`, then verify the same package across the CI matrix. For visual changes, check light and dark themes and include before/after screenshots. ## Status diff --git a/build/build.mjs b/build/build.mjs index fc0b276..fa2c22e 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -21,6 +21,7 @@ import {FOLDERS, THEMES, SRC, DIST, ROOT, CUSTOM_PATH, GITEA_URL, GITEA_CONTAINE import {lintAll} from './lint.mjs'; import {nest, verifyNest} from './nest.mjs'; import {pageScopeCheck} from './page-scope-check.mjs'; +import {sourceDirectories} from './gitea-versions.mjs'; const argv = process.argv.slice(2); const flag = (n) => argv.includes(`--${n}`); @@ -28,6 +29,15 @@ const opt = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + const excluded = new Set((opt('exclude') || '').split(',').filter(Boolean)); const report = {startedAt: new Date().toISOString(), folders: {}, themes: {}, deploy: null}; const read = (p) => fs.readFileSync(p, 'utf8'); +const upstream = sourceDirectories(); // Fail closed, including builds with --no-lint. +report.giteaVersions = upstream.map(s => s.version); +const externalProperties = new Set(); +for (const dir of [...upstream.flatMap(s => ['web_src', 'templates'].map(d => path.join(s.directory, d))), path.join(ROOT, 'templates')]) { + for (const f of fs.readdirSync(dir, {recursive: true}).sort()) { + if (!/\.(tmpl|ts|js|vue|css|go)$/.test(f)) continue; + for (const m of read(path.join(dir, f)).matchAll(/--[\w-]+/g)) externalProperties.add(m[0]); + } +} // Browsers with CSS nesting (`&`): Chrome/Edge 112+, Safari 16.5+, Firefox 117+ (build/nest.mjs output needs it). const TARGETS = {chrome: 120 << 16, firefox: 117 << 16, safari: (16 << 16) | (5 << 8)}; const NEST = !flag('no-nest'); @@ -90,6 +100,7 @@ function prune(allComponentCss) { } const used = new Set(); const queue = [...(giteaMap + schemeCss.light + schemeCss.dark + allComponentCss).matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1]); + queue.push(...[...externalProperties].filter(name => defs.has(name))); // Also retain upstream-only Primer tokens. while (queue.length) { const n = queue.pop(); if (used.has(n)) continue; @@ -145,20 +156,8 @@ const RENAME = !flag('no-rename'); const shortNames = new Map(); if (RENAME) { const ours = new Set([...(colorTokens.light + colorTokens.dark + scaleTokens + masksCss).matchAll(/(--[\w-]+)\s*:/g)].map((m) => m[1])); - const keepVerbatim = new Set(); - const foreign = new Set(); // every custom-property name Gitea's source or our templates mention: never generated - const scan = (dir) => { - if (!fs.existsSync(dir)) return; - for (const f of fs.readdirSync(dir, {recursive: true})) { - if (!/\.(tmpl|ts|js|vue|css|go)$/.test(f)) continue; - for (const m of read(path.join(dir, f)).matchAll(/--[\w-]+/g)) { - foreign.add(m[0]); - if (ours.has(m[0])) keepVerbatim.add(m[0]); - } - } - }; - const GITEA_SRC = process.env.GITEA_SRC || path.resolve(ROOT, '../gitea-src-1.27.3'); - for (const d of [path.join(GITEA_SRC, 'web_src'), path.join(GITEA_SRC, 'templates'), path.join(ROOT, 'templates')]) scan(d); + const foreign = externalProperties; + const keepVerbatim = new Set([...foreign].filter(n => ours.has(n))); for (const n of keepVerbatim) ours.delete(n); report.renamedKeptVerbatim = [...keepVerbatim].sort(); // Loop 2 (integrator L2b, budget): identical-token dedupe. Primer tokens whose definitions are identical in every diff --git a/build/fetch-gitea.mjs b/build/fetch-gitea.mjs new file mode 100644 index 0000000..dfbd5cd --- /dev/null +++ b/build/fetch-gitea.mjs @@ -0,0 +1,33 @@ +// Cache only the upstream templates and frontend sources used by compatibility checks. +import fs from 'node:fs'; +import path from 'node:path'; +import {execFileSync} from 'node:child_process'; +import {pipeline} from 'node:stream/promises'; +import {Readable} from 'node:stream'; +import {GITEA_VERSIONS, SOURCE_ROOT, sourceDirectory} from './gitea-versions.mjs'; + +const requested = process.argv.slice(2); +const versions = requested.length ? requested : GITEA_VERSIONS; +for (const version of versions) { + if (!GITEA_VERSIONS.includes(version)) throw new Error(`Unsupported Gitea test version: ${version}`); + try { + console.log(`Gitea ${version}: ${sourceDirectory(version)}`); + continue; + } catch { /* Fetch a missing source tree; never change an existing development checkout. */ } + fs.mkdirSync(SOURCE_ROOT, {recursive: true}); + const tmp = fs.mkdtempSync(path.join(SOURCE_ROOT, '.download-')); + try { + const archive = path.join(tmp, 'source.tar.gz'); + const response = await fetch(`https://codeload.github.com/go-gitea/gitea/tar.gz/refs/tags/v${version}`, {signal: AbortSignal.timeout(120000)}); + if (!response.ok) throw new Error(`Gitea ${version}: download returned HTTP ${response.status}`); + await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(archive)); + const extracted = path.join(tmp, 'source'); + fs.mkdirSync(extracted); + execFileSync('tar', ['-xzf', archive, '--strip-components=1', '-C', extracted, `gitea-${version}/templates`, `gitea-${version}/web_src`]); + fs.writeFileSync(path.join(extracted, 'upstream-version'), `${version}\n`); + fs.renameSync(extracted, path.join(SOURCE_ROOT, version)); + console.log(`Gitea ${version}: downloaded into ${sourceDirectory(version)}`); + } finally { + fs.rmSync(tmp, {recursive: true, force: true}); + } +} diff --git a/build/gitea-versions.json b/build/gitea-versions.json new file mode 100644 index 0000000..3955243 --- /dev/null +++ b/build/gitea-versions.json @@ -0,0 +1,4 @@ +{ + "series": ["1.27", "28"], + "versions": ["1.27.3", "28.0.0", "28.1.0", "28.2.0", "28.3.0"] +} diff --git a/build/gitea-versions.mjs b/build/gitea-versions.mjs new file mode 100644 index 0000000..406e81b --- /dev/null +++ b/build/gitea-versions.mjs @@ -0,0 +1,28 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {ROOT} from './folders.mjs'; + +// The build, package metadata and CI matrix share this list. Adding a version requires testing it. +const config = JSON.parse(fs.readFileSync(new URL('./gitea-versions.json', import.meta.url), 'utf8')); +export const GITEA_VERSIONS = config.versions; +export const GITEA_SERIES = config.series; +export const GITEA_PACKAGE_SUFFIX = GITEA_SERIES.join('-'); +export const SOURCE_ROOT = path.resolve(process.env.GITEA_SOURCE_ROOT || path.join(ROOT, '.cache/gitea-sources')); + +export function sourceDirectory(version) { + if (!GITEA_VERSIONS.includes(version)) throw new Error(`Unsupported Gitea test version: ${version}`); + const dirs = [path.join(SOURCE_ROOT, version)]; + // Keep the existing read-only development checkouts usable without copying or modifying them. + if (!process.env.GITEA_SOURCE_ROOT) dirs.push(path.resolve(ROOT, `../gitea-src-${version}`)); + const found = dirs.find(dir => ['templates', 'web_src'].every(name => fs.existsSync(path.join(dir, name)))); + if (!found) throw new Error(`Gitea ${version} sources missing. Run npm run upstream (or set GITEA_SOURCE_ROOT).`); + return found; +} + +export function sourceDirectories() { + return GITEA_VERSIONS.map(version => ({version, directory: sourceDirectory(version)})); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + console.log(JSON.stringify({version: GITEA_VERSIONS})); +} diff --git a/build/package.mjs b/build/package.mjs index 1ae6b85..9a6f696 100644 --- a/build/package.mjs +++ b/build/package.mjs @@ -15,11 +15,13 @@ import fs from 'node:fs'; import path from 'node:path'; import {execFileSync} from 'node:child_process'; import {ROOT, DIST, SRC, THEMES} from './folders.mjs'; +import {GITEA_VERSIONS, GITEA_SERIES, GITEA_PACKAGE_SUFFIX} from './gitea-versions.mjs'; -const GITEA_SUPPORTED = '1.27-28'; // tested on 1.27.3 and 28.1.0 -const GITEA_TESTED = '1.27.3 and 28.1.0'; +const GITEA_SUPPORTED = GITEA_PACKAGE_SUFFIX; +const GITEA_TESTED = GITEA_VERSIONS.join(', '); const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); const report = JSON.parse(fs.readFileSync(path.join(DIST, 'build-report.json'), 'utf8')); +if (JSON.stringify(report.giteaVersions) !== JSON.stringify(GITEA_VERSIONS)) throw new Error('Build source matrix is stale; rebuild before packaging.'); const name = `gitea-theme-github-${pkg.version}-gitea${GITEA_SUPPORTED}`; const out = path.join(DIST, 'package', 'gitea-theme-github'); @@ -55,6 +57,7 @@ for (const rel of fs.readdirSync(tplRoot, {recursive: true}).filter((f) => f.end } for (const f of ['LICENSE', 'NOTICE']) copy(path.join(ROOT, f), path.join(out, f)); +fs.writeFileSync(path.join(out, 'COMPATIBILITY.json'), JSON.stringify({series: GITEA_SERIES, versions: GITEA_VERSIONS, buildRevision: report.revision}, null, 2) + '\n'); fs.writeFileSync(path.join(out, 'INSTALL.md'), `# gitea-theme-github ${pkg.version} (for Gitea 1.27.x and 28.x, tested on ${GITEA_TESTED}) 1. Copy the contents of this folder into Gitea's custom directory (\`$GITEA_CUSTOM\`, \`/data/gitea\` in the official diff --git a/build/page-scope-check.mjs b/build/page-scope-check.mjs index 538f719..fd8ed28 100644 --- a/build/page-scope-check.mjs +++ b/build/page-scope-check.mjs @@ -9,14 +9,14 @@ // of its classes is a Gitea page class (vocabulary = union of the class sets found in step 2, minus `ui`, `container`, // `page-content`, `tw-*`). `:not(.a, .b)` on that compound becomes a negative set. Selectors whose first class // compound is not made of page classes are "unscoped" (component-style rules inside a page folder) and not checked. -// 2. Templates → page classes (static). gitea-src-1.27.3/templates/**/*.tmpl + our templates/ overrides (they win) + -// the Modern theme's overrides in CUSTOM_PATH/templates (read-only): every `class="page-content …"` attribute is +// 2. Templates → page classes (static). each supported Gitea version's templates + our templates/ overrides (they win); +// with --live, also CUSTOM_PATH/templates (read-only): every `class="page-content …"` attribute is // expanded over its `{{if}}…{{else}}…{{end}}` branches; `{{.pageClass}}` is resolved through every // `(dict "pageClass" "…")` call site of that layout template (the caller is reported as the template). // 3. Routes → page classes (live, authoritative). With --live every route of tools/shoot/routes.json (+ EXTRA_URLS, // the known shared shells that no route covers) is fetched from Gitea (admin session from the shoot tool's storage // state; anonymous for `auth: false`; the `style` query parameter is dropped so the admin's diff-style preference is -// never written) and the classes of the first `.page-content` element are cached in shots/page-classes.json. +// never written) and page classes are cached by version and instance URL under .cache/page-scope/. // Without --live the cache is used when present. // 4. Match: a scope matches a page when its classes ⊆ the page's classes and no negative set ⊆ the page's classes. // 5. Intent: src/pages//scopes.json declares, per scope key (sorted classes, e.g. ".commits.repository" or @@ -29,12 +29,13 @@ // Exit code: 0; with --strict 1 when there is any LEAK (build.mjs --strict and the final gate use that). import fs from 'node:fs'; import path from 'node:path'; +import crypto from 'node:crypto'; +import {GITEA_VERSIONS, sourceDirectory} from './gitea-versions.mjs'; import postcss from 'postcss'; import {parse as parseSelector} from 'css-what'; // transitive dependency of svgo (pinned by package-lock.json) import {ROOT, SRC, CUSTOM_PATH, GITEA_URL, PAGE_GROUPS} from './folders.mjs'; -const GITEA_SRC = path.resolve(ROOT, '../gitea-src-1.27.3'); -const CACHE = path.join(ROOT, 'shots/page-classes.json'); +const cacheRoot = path.join(ROOT, '.cache/page-scope'); const REPORT_MD = path.join(ROOT, 'docs/page-scope-report.md'); const REPORT_JSON = path.join(ROOT, 'docs/page-scope-report.json'); const GENERIC = new Set(['ui', 'container', 'page-content']); @@ -136,10 +137,10 @@ function expandAttr(value, pageClassValues) { return {variants: out.map((s) => s.split(/\s+/).filter(Boolean)), unknown}; } -function scanTemplates() { +function scanTemplates(source, live) { const sources = new Map(); // rel → {file, origin} - for (const rel of listTemplates(path.join(GITEA_SRC, 'templates'))) sources.set(rel, {file: path.join(GITEA_SRC, 'templates', rel), origin: 'upstream'}); - for (const rel of listTemplates(path.join(CUSTOM_PATH, 'templates'))) sources.set(rel, {file: path.join(CUSTOM_PATH, 'templates', rel), origin: 'custom (live)'}); + for (const rel of listTemplates(path.join(source, 'templates'))) sources.set(rel, {file: path.join(source, 'templates', rel), origin: 'upstream'}); + if (live) for (const rel of listTemplates(path.join(CUSTOM_PATH, 'templates'))) sources.set(rel, {file: path.join(CUSTOM_PATH, 'templates', rel), origin: 'custom (live)'}); for (const rel of listTemplates(path.join(ROOT, 'templates'))) sources.set(rel, {file: path.join(ROOT, 'templates', rel), origin: 'ours'}); const text = new Map([...sources].map(([rel, s]) => [rel, fs.readFileSync(s.file, 'utf8')])); // pageClass call sites: {{template "admin/layout_head" (dict "pageClass" "admin config")}} @@ -186,16 +187,18 @@ function scanTemplates() { // --------------------------------------------------------------------------------------------------------------------- // 3. live routes → class sets -async function fetchLive() { +async function fetchLive(cache, version) { + const running = await (await fetch(`${GITEA_URL}/api/v1/version`)).json(); + if (running.version?.split(/[+-]/)[0] !== version) throw new Error(`Expected Gitea ${version}, got ${running.version} at ${GITEA_URL}`); const cfg = JSON.parse(fs.readFileSync(path.join(ROOT, 'tools/shoot/routes.json'), 'utf8')); - const stateFile = path.join(ROOT, '.cache/gitea-admin-storage.json'); + const stateFile = process.env.GITEA_STORAGE_STATE || path.join(ROOT, '.cache/gitea-admin-storage.json'); let cookie = 'lang=en-US'; if (fs.existsSync(stateFile)) { const st = JSON.parse(fs.readFileSync(stateFile, 'utf8')); cookie = st.cookies.map((c) => `${c.name}=${c.value}`).join('; '); } const targets = [...cfg.routes.map((r) => ({id: r.id, url: r.gitea, anon: r.auth === false})), ...EXTRA_URLS]; - const out = {generated: new Date().toISOString(), gitea: GITEA_URL, routes: {}}; + const out = {version, generated: new Date().toISOString(), gitea: GITEA_URL, routes: {}}; for (const r of targets) { const u = new URL(r.url, GITEA_URL); u.searchParams.delete('style'); // never write the admin's diff-style preference @@ -210,8 +213,8 @@ async function fetchLive() { out.routes[r.id] = {url: r.url, error: e.message}; } } - fs.mkdirSync(path.dirname(CACHE), {recursive: true}); - fs.writeFileSync(CACHE, JSON.stringify(out, null, 1)); + fs.mkdirSync(path.dirname(cache), {recursive: true}); + fs.writeFileSync(cache, JSON.stringify(out, null, 1)); return out; } @@ -339,7 +342,7 @@ async function probeLeaks(result, liveData) { const {chromium} = await import('playwright'); const {launchBrowser} = await import('../tools/shoot/lib/browser.mjs').catch(() => ({})); const browser = launchBrowser ? await launchBrowser() : await chromium.launch(); - const stateFile = path.join(ROOT, '.cache/gitea-admin-storage.json'); + const stateFile = process.env.GITEA_STORAGE_STATE || path.join(ROOT, '.cache/gitea-admin-storage.json'); const ctxs = {admin: await browser.newContext({storageState: fs.existsSync(stateFile) ? stateFile : undefined, locale: 'en-US'}), anon: await browser.newContext({locale: 'en-US'})}; for (const c of Object.values(ctxs)) await c.addCookies([{name: 'lang', value: 'en-US', url: GITEA_URL}]); @@ -364,14 +367,20 @@ async function probeLeaks(result, liveData) { await browser.close(); } -export async function pageScopeCheck({live = false, seed = false, write = true, probe = false} = {}) { - const templates = scanTemplates(); +async function checkVersion({version, live = false, seed = false, write = true, probe = false}) { + const source = sourceDirectory(version); + const dir = path.join(cacheRoot, version); + const cache = path.join(dir, `live-${crypto.createHash('sha256').update(GITEA_URL).digest('hex').slice(0, 12)}.json`); + const templates = scanTemplates(source, live || probe); const vocab = new Set(templates.flatMap((t) => t.classes).filter((c) => !GENERIC.has(c))); let liveData = null; - if (live) liveData = await fetchLive(); - else if (fs.existsSync(CACHE)) liveData = JSON.parse(fs.readFileSync(CACHE, 'utf8')); + if (live || probe) liveData = await fetchLive(cache, version); + else if (fs.existsSync(cache)) { + const cached = JSON.parse(fs.readFileSync(cache, 'utf8')); + if (cached.version === version && cached.gitea === GITEA_URL) liveData = cached; + } const routes = liveData ? Object.entries(liveData.routes).filter(([, r]) => r.classes) : []; - const result = {generated: new Date().toISOString(), liveCache: liveData ? path.relative(ROOT, CACHE) : null, + const result = {version, source, generated: new Date().toISOString(), liveCache: liveData ? path.relative(ROOT, cache) : null, liveGenerated: liveData?.generated || null, templatesScanned: templates.length, folders: {}, totals: {scopes: 0, leaks: 0, newScopes: 0, dead: 0}}; for (const group of PAGE_GROUPS) { const {scopes, unscoped} = collectScopes(group, vocab); @@ -406,6 +415,29 @@ export async function pageScopeCheck({live = false, seed = false, write = true, if (probe && liveData) await probeLeaks(result, liveData); for (const f of Object.values(result.folders)) for (const row of f.rows) delete row.selectorsAll; if (write) { + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync(path.join(dir, 'report.json'), JSON.stringify(result, null, 1)); + fs.writeFileSync(path.join(dir, 'report.md'), toMarkdown(result)); + } + return result; +} + +// The default checks every supported source independently, rather than merging template paths across versions. +export async function pageScopeCheck(options = {}) { + if ((options.live || options.probe) && !options.version) throw new Error('--live/--probe requires --version to select the running Gitea'); + if (options.version) return checkVersion(options); + const versions = []; + for (const version of GITEA_VERSIONS) versions.push(await checkVersion({...options, version})); + const result = {generated: new Date().toISOString(), versions: GITEA_VERSIONS, templatesScanned: versions.reduce((n, r) => n + r.templatesScanned, 0), + folders: {}, totals: {scopes: 0, leaks: 0, newScopes: 0, dead: 0}}; + for (const group of PAGE_GROUPS) { + const parts = versions.map(r => ({...r.folders[group], rows: r.folders[group].rows.map(row => ({...row, version: r.version}))})); + result.folders[group] = {rows: parts.flatMap(p => p.rows), staleDeclarations: [...new Set(parts.flatMap(p => p.staleDeclarations))]}; + for (const key of ['scopes', 'unscopedSelectors', 'leaks', 'newScopes', 'dead']) result.folders[group][key] = parts.reduce((n, p) => n + p[key], 0); + } + for (const key of Object.keys(result.totals)) result.totals[key] = versions.reduce((n, r) => n + r.totals[key], 0); + if (options.write !== false) { + fs.mkdirSync(path.dirname(REPORT_JSON), {recursive: true}); fs.writeFileSync(REPORT_JSON, JSON.stringify(result, null, 1)); fs.writeFileSync(REPORT_MD, toMarkdown(result)); } @@ -415,17 +447,17 @@ export async function pageScopeCheck({live = false, seed = false, write = true, function toMarkdown(r) { const L = []; L.push('# Page-scope report (FG2-105)', ''); - L.push(`Generated ${r.generated} by \`node build/page-scope-check.mjs${r.liveCache ? '' : ''}\`. Templates scanned: ${r.templatesScanned} (upstream 1.27.3 + CUSTOM_PATH + ours); live page classes: ${r.liveCache ? `${r.liveCache} (${r.liveGenerated})` : 'none (run with --live)'}.`); + L.push(`Generated ${r.generated} by \`node build/page-scope-check.mjs${r.version ? ` --version ${r.version}` : ''}\`. Templates scanned: ${r.templatesScanned} (upstream ${r.version || r.versions.join(", ")} + ours (live custom overrides only with --live)); live page classes: ${r.liveCache ? `${r.liveCache} (${r.liveGenerated})` : 'none (run with --live)'}.`); L.push('', `Totals: ${r.totals.scopes} scopes, **${r.totals.leaks} LEAK**, ${r.totals.newScopes} NEW (undeclared), ${r.totals.dead} DEAD (match nothing).`, ''); L.push('Scope = class set of the `.page-content` compound (or of the leading page-class compound). A LEAK is a template the scope', 'matches that `src/pages//scopes.json` neither intends nor waives. Declare intent there; waive with a reason.', ''); - L.push('| folder | scope | status | sel. | first rule | matched templates | matched routes |', '|---|---|---|---|---|---|---|'); + L.push('| version | folder | scope | status | sel. | first rule | matched templates | matched routes |', '|---|---|---|---|---|---|---|---|'); const order = {LEAK: 0, NEW: 1, DEAD: 2, ok: 3}; const rows = Object.entries(r.folders).flatMap(([g, f]) => f.rows.map((x) => ({g, ...x}))).sort((a, b) => order[a.status] - order[b.status] || a.g.localeCompare(b.g) || a.scope.localeCompare(b.scope)); for (const x of rows) { const t = x.templates.map((t) => (x.leaks.includes(t) ? `**${t}**` : t in (x.waived || {}) ? `~~${t}~~` : t)).join(', ') || '—'; const rt = x.routes.map((q) => (x.leakRoutes.some((l) => l.id === q.id) ? `**${q.id}**` : q.id)).join(', ') || '—'; const pr = (x.probe || []).map((p) => `${p.route}: ${p.applied}/${p.of} applied`).join('; '); - L.push(`| ${x.g} | \`${x.scope}\` | ${x.status}${pr ? ` (probe ${pr})` : ''} | ${x.selectors} | ${x.first} | ${t} | ${rt} |`); + L.push(`| ${x.version || r.version} | ${x.g} | \`${x.scope}\` | ${x.status}${pr ? ` (probe ${pr})` : ''} | ${x.selectors} | ${x.first} | ${t} | ${rt} |`); } L.push('', 'Bold = leak (unintended match); struck = waived (reason in scopes.json).', ''); for (const [g, f] of Object.entries(r.folders)) { @@ -439,7 +471,9 @@ function toMarkdown(r) { } if (import.meta.url === `file://${process.argv[1]}`) { - const r = await pageScopeCheck({live: flag('live'), seed: flag('seed'), probe: flag('probe')}); + const version = process.argv[process.argv.indexOf('--version') + 1]; + if (flag('version') && (!version || version.startsWith('--'))) throw new Error('--version requires a Gitea version'); + const r = await pageScopeCheck({version: flag('version') ? version : undefined, live: flag('live'), seed: flag('seed'), probe: flag('probe')}); if (!flag('quiet')) { for (const [g, f] of Object.entries(r.folders)) { console.log(`${f.leaks ? '!' : '✓'} pages/${g}: ${f.scopes} scopes, ${f.leaks} LEAK, ${f.newScopes} NEW, ${f.dead} DEAD, ${f.unscopedSelectors} unscoped selectors${f.staleDeclarations.length ? `, stale declarations: ${f.staleDeclarations.join(' ')}` : ''}`); @@ -448,7 +482,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { for (const p of x.probe || []) console.log(` probe ${p.route}: ${p.applied}/${p.of} selectors match elements${p.examples.length ? ` e.g. ${p.examples.slice(0, 2).map((e) => `${e.sel} (${e.count}) @${e.at}`).join(' | ')}` : ''}`); } } - console.log(`page-scope: ${r.totals.leaks} LEAK · report ${path.relative(ROOT, REPORT_MD)}`); + console.log(`page-scope: ${r.totals.leaks} LEAK · report ${path.relative(ROOT, r.version ? path.join(cacheRoot, r.version, 'report.md') : REPORT_MD)}`); } process.exit(flag('strict') && r.totals.leaks ? 1 : 0); } diff --git a/build/tests/compatibility.test.mjs b/build/tests/compatibility.test.mjs new file mode 100644 index 0000000..17d5d8c --- /dev/null +++ b/build/tests/compatibility.test.mjs @@ -0,0 +1,102 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {spawn, spawnSync} from 'node:child_process'; +import http from 'node:http'; +import {GITEA_VERSIONS as versions} from '../gitea-versions.mjs'; + +const root = path.resolve(import.meta.dirname, '../..'); + +function fixture(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'github-theme-build-')); + t.after(() => fs.rmSync(dir, {recursive: true, force: true})); + fs.cpSync(path.join(root, 'build'), path.join(dir, 'build'), {recursive: true}); + fs.cpSync(path.join(root, 'src'), path.join(dir, 'src'), {recursive: true}); + fs.cpSync(path.join(root, 'templates'), path.join(dir, 'templates'), {recursive: true}); + fs.copyFileSync(path.join(root, 'package.json'), path.join(dir, 'package.json')); + for (const name of ['LICENSE', 'NOTICE']) fs.copyFileSync(path.join(root, name), path.join(dir, name)); + fs.mkdirSync(path.join(dir, 'docs')); + fs.symlinkSync(path.join(root, 'node_modules'), path.join(dir, 'node_modules'), 'dir'); + const write = (name, value) => { + const file = path.join(dir, name); + fs.mkdirSync(path.dirname(file), {recursive: true}); + fs.writeFileSync(file, value); + }; + for (const v of versions) { + write(`upstream/${v}/web_src/css/base.css`, ':root { --color-primary: blue; }'); + write(`upstream/${v}/templates/repo/allowed.tmpl`, '
'); + } + return {dir, write, run: (...args) => spawnSync(process.execPath, args, { + cwd: dir, encoding: 'utf8', env: {...process.env, GITEA_SOURCE_ROOT: path.join(dir, 'upstream'), GITEA_CUSTOM_PATH: path.join(dir, 'no-live-custom')}, + })}; +} + +test('one CSS build reserves names and retains tokens referenced by any supported upstream', t => { + const f = fixture(t); + f.write('upstream/28.3.0/web_src/css/new.css', '.new { --a: 1; width: var(--base-size-112); border-radius: var(--borderRadius-medium); }'); + const built = f.run('build/build.mjs', '--no-lint'); + assert.equal(built.status, 0, built.stderr); + const map = JSON.parse(fs.readFileSync(path.join(f.dir, 'dist/varmap.json'))); + assert.equal(Object.hasOwn(map, '--a'), false, 'upstream --a must not be generated as a short name'); + const css = fs.readFileSync(path.join(f.dir, 'dist/theme-github-light.css'), 'utf8'); + assert.match(css, /--base-size-112\s*:/, 'an upstream-only token must survive pruning and renaming'); + assert.match(css, /--borderRadius-medium\s*:/, 'upstream references must keep the original token name'); +}); + +test('a release build refuses to silently skip missing supported sources', t => { + const f = fixture(t); + fs.rmSync(path.join(f.dir, 'upstream/28.2.0'), {recursive: true}); + const built = f.run('build/build.mjs', '--strict'); + assert.notEqual(built.status, 0); + assert.match(built.stdout + built.stderr, /28\.2\.0.*(missing|not found)|missing.*28\.2\.0/i); +}); + +test('the single package documents every version in the compatibility matrix', t => { + const f = fixture(t); + const built = f.run('build/build.mjs', '--no-lint'); + assert.equal(built.status, 0, built.stderr); + const packaged = f.run('build/package.mjs'); + assert.equal(packaged.status, 0, packaged.stderr); + const install = fs.readFileSync(path.join(f.dir, 'dist/package/gitea-theme-github/INSTALL.md'), 'utf8'); + for (const version of versions) assert.ok(install.includes(version), `INSTALL.md must list ${version}`); +}); + +test('scope checks detect new upstream pages per version without sharing old live caches', t => { + const f = fixture(t); + f.write('src/pages/repo/index.css', '.repository.compat-test .button { color: var(--fgColor-default); }'); + f.write('src/pages/repo/scopes.json', JSON.stringify({'.compat-test.repository': ['repo/allowed.tmpl']})); + f.write('upstream/28.3.0/templates/repo/new.tmpl', '
'); + f.write('shots/page-classes.json', JSON.stringify({gitea: 'http://old-instance:3000', routes: {stale: {classes: ['repository', 'compat-test']}}})); + const result = f.run('--input-type=module', '-e', ` + import {pageScopeCheck} from './build/page-scope-check.mjs'; + const latest = await pageScopeCheck({version: '28.3.0', write: false}); + const old = await pageScopeCheck({version: '28.0.0', write: false}); + console.log(JSON.stringify({latest, old})); + `); + assert.equal(result.status, 0, result.stderr); + const {latest, old} = JSON.parse(result.stdout); + const row = report => report.folders.repo.rows.find(r => r.scope === '.compat-test.repository'); + assert.equal(row(latest)?.status, 'LEAK'); + assert.deepEqual(row(latest).leaks, ['repo/new.tmpl']); + assert.equal(row(old)?.status, 'ok'); + assert.deepEqual(row(old).routes, [], 'unversioned cache from another server must be ignored'); +}); + +test('live probes reject an instance running another Gitea version before opening a browser', async t => { + const f = fixture(t); + const server = http.createServer((req, res) => { res.setHeader('Content-Type', 'application/json'); res.end('{"version":"28.1.0"}'); }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => server.close()); + const child = spawn(process.execPath, ['--input-type=module', '-e', ` + import {pageScopeCheck} from './build/page-scope-check.mjs'; + await pageScopeCheck({version: '28.3.0', probe: true, write: false}); + `], {cwd: f.dir, env: {...process.env, GITEA_SOURCE_ROOT: path.join(f.dir, 'upstream'), GITEA_URL: `http://127.0.0.1:${server.address().port}`}}); + let output = ''; + child.stdout.on('data', data => output += data); + child.stderr.on('data', data => output += data); + const code = await new Promise(resolve => child.on('close', resolve)); + assert.notEqual(code, 0); + assert.match(output, /Expected Gitea 28\.3\.0, got 28\.1\.0/); +}); diff --git a/package.json b/package.json index 7a51c08..91a70a7 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "seed": "node tools/seed/seed.mjs", "shoot": "node tools/shoot/shoot.mjs", "smoke": "node tools/shoot/smoke.mjs", - "package": "node build/package.mjs" + "package": "node build/package.mjs", + "test": "node --test build/tests/*.test.mjs", + "upstream": "node build/fetch-gitea.mjs", + "compat": "node tools/compat/check.mjs" }, "devDependencies": { "@primer/css": "22.3.2", diff --git a/src/pages/actions-packages-projects/scopes.json b/src/pages/actions-packages-projects/scopes.json index b472276..62d50b9 100644 --- a/src/pages/actions-packages-projects/scopes.json +++ b/src/pages/actions-packages-projects/scopes.json @@ -1,6 +1,8 @@ { ".actions.repository": { "intended": [ + "repo/actions/artifact_preview.tmpl", + "repo/actions/job_queue.tmpl", "repo/actions/list.tmpl" ], "waived": { diff --git a/src/pages/people/scopes.json b/src/pages/people/scopes.json index 76b8edf..2e9c5eb 100644 --- a/src/pages/people/scopes.json +++ b/src/pages/people/scopes.json @@ -49,6 +49,7 @@ "org/settings/actions.tmpl", "org/settings/applications.tmpl", "org/settings/applications_oauth2_edit.tmpl", + "org/settings/audit_logs.tmpl", "org/settings/blocked_users.tmpl", "org/settings/hook_new.tmpl", "org/settings/hooks.tmpl", @@ -90,6 +91,7 @@ "org/settings/actions.tmpl", "org/settings/applications.tmpl", "org/settings/applications_oauth2_edit.tmpl", + "org/settings/audit_logs.tmpl", "org/settings/blocked_users.tmpl", "org/settings/hook_new.tmpl", "org/settings/hooks.tmpl", diff --git a/src/pages/settings-admin/scopes.json b/src/pages/settings-admin/scopes.json index dc74d5c..9f36374 100644 --- a/src/pages/settings-admin/scopes.json +++ b/src/pages/settings-admin/scopes.json @@ -3,6 +3,7 @@ "admin/actions.tmpl", "admin/applications/list.tmpl", "admin/applications/oauth2_edit.tmpl", + "admin/audit/list.tmpl", "admin/auth/edit.tmpl", "admin/auth/list.tmpl", "admin/auth/new.tmpl", @@ -42,6 +43,7 @@ "org/settings/actions.tmpl", "org/settings/applications.tmpl", "org/settings/applications_oauth2_edit.tmpl", + "org/settings/audit_logs.tmpl", "org/settings/blocked_users.tmpl", "org/settings/hook_new.tmpl", "org/settings/hooks.tmpl", @@ -62,6 +64,7 @@ "org/settings/actions.tmpl", "org/settings/applications.tmpl", "org/settings/applications_oauth2_edit.tmpl", + "org/settings/audit_logs.tmpl", "org/settings/blocked_users.tmpl", "org/settings/hook_new.tmpl", "org/settings/hooks.tmpl", @@ -73,6 +76,7 @@ "org/settings/runners_edit.tmpl", "package/settings.tmpl", "repo/settings/actions.tmpl", + "repo/settings/audit_logs.tmpl", "repo/settings/branches.tmpl", "repo/settings/collaboration.tmpl", "repo/settings/deploy_keys.tmpl", @@ -96,6 +100,7 @@ "user/settings/appearance.tmpl", "user/settings/applications.tmpl", "user/settings/applications_oauth2_edit.tmpl", + "user/settings/audit_logs.tmpl", "user/settings/blocked_users.tmpl", "user/settings/hook_new.tmpl", "user/settings/hooks.tmpl", diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index 892dc84..69178db 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -1,4 +1,4 @@ -{{if StringUtils.HasPrefix ctx.CurrentWebTheme.InternalName "github-"}}{{/* gitea-theme-github (ARCHITECTURE.md §7, FG-007): github.com AppHeader for github-* themes only, markup in custom/gh_head_navbar.tmpl. The other two branches are Gitea's templates/base/head_navbar.tmpl verbatim: 1.27.3 when AppVer starts with "1.", else 28.1.0; no newline after the final end, so other themes render upstream bytes exactly. */}}{{template "custom/gh_head_navbar" .}}{{else if StringUtils.HasPrefix AppVer "1."}}