diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b14f2e..a1cacfa 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,7 +8,7 @@ on: workflow_dispatch: permissions: - contents: write + contents: read jobs: visual: @@ -21,50 +19,121 @@ jobs: build: needs: visual 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/.github/workflows/visual.yml b/.github/workflows/visual.yml index 48e5815..3a1151b 100644 --- a/.github/workflows/visual.yml +++ b/.github/workflows/visual.yml @@ -27,6 +27,10 @@ jobs: node-version: 24 cache: npm - run: npm ci + - uses: actions/cache@v6 + with: + path: .cache/gitea-sources + key: gitea-sources-v1-${{ hashFiles('build/gitea-versions.json') }} - name: Install the pinned Chromium browser and its system dependencies run: npx playwright install --with-deps chromium - name: Compare the candidate with the approved visual baseline 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 ada3340..3feb6ab 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,33 +161,64 @@ 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. visual regression on Gitea 1.27.3 and 28.2.0 (light/dark, desktop/mobile), with screenshot diffs and reports; -2. lint; -3. build; -4. package; -5. upload the package as a workflow artifact. - -The visual jobs must pass before packaging or publishing. They create their own fixed test data and +2. run build regression tests, fetch all matrix versions' upstream sources, and check selectors against each version; +3. build the release candidate with strict checks and the **340 KB** budget, then package **once** for the compatibility matrix; +4. install that unchanged archive in a fresh container for every matrix version; +5. 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; +6. retain screenshots, logs and a JSON report recording the archive SHA-256 for each version. + +The visual jobs must pass before packaging the release candidate or publishing. They create their own fixed test data and compare the candidate with an explicitly approved source revision in the same CI environment. See [`tools/visual/README.md`](tools/visual/README.md) for local runs, reports and baseline updates. -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: +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 -npm version patch && git push --follow-tags +npx playwright install chromium +npm run package +npm run compat -- --version 28.3.0 --package dist/gitea-theme-github--gitea1.27-28.tar.gz ``` +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 +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 39f2610..cc81166 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,11 @@ "shoot": "node tools/shoot/shoot.mjs", "smoke": "node tools/shoot/smoke.mjs", "test:visual": "node tools/visual/run.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."}}