diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..6bd6bcb --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,15 @@ +name: 'Setup Node.js environment' +description: 'Configure Node.js 22 with npm cache and install dependencies' + +runs: + using: composite + steps: + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + shell: bash diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/ci.yml similarity index 56% rename from .github/workflows/unit-tests.yml rename to .github/workflows/ci.yml index 1f4fd11..7f4d83a 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,8 @@ -name: Unit Tests +name: CI on: + schedule: + - cron: '0 9 * * 1' # Every Monday at 09:00 UTC push: branches: - main @@ -14,13 +16,16 @@ jobs: changes: runs-on: ubuntu-latest outputs: - code: ${{ steps.filter.outputs.code }} + # On scheduled runs there is no diff to compare — always run tests. + # On push/PR, only run when relevant files changed. + code: ${{ github.event_name == 'schedule' || steps.filter.outputs.code == 'true' }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Detect relevant file changes uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + if: github.event_name != 'schedule' id: filter with: filters: | @@ -36,17 +41,8 @@ jobs: if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup - name: Build generated files needed for tests run: npm run build:milestones && npm run build:js @@ -65,6 +61,23 @@ jobs: verbose: true fail_ci_if_error: false + test-e2e: + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Build generated files + run: npm run build + + - name: Run E2E tests + run: npm run test:e2e + unit-test-status: needs: [changes, test] if: always() @@ -77,3 +90,16 @@ jobs: exit 1 fi echo "Unit tests passed or were skipped (no relevant files changed)" + + e2e-test-status: + needs: [changes, test-e2e] + if: always() + runs-on: ubuntu-latest + steps: + - name: Report status + run: | + if [[ "${{ needs.test-e2e.result }}" == "failure" ]]; then + echo "E2E tests failed" + exit 1 + fi + echo "E2E tests passed or were skipped (no relevant files changed)" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 94cc553..fdde754 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,32 +20,11 @@ jobs: name: github-pages url: https://nitrocode.github.io/token-deathclock/ steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Regenerate milestones-data.js from YAML - run: npm run build:milestones - - - name: Regenerate changelog-data.js from CHANGELOG.md - run: npm run build:changelog - - - name: Regenerate project-stats-data.js from YAML - run: npm run build:project-stats - - - name: Rebuild script.js from src/js/ source files - run: npm run build:js - - - name: Rebuild styles.css from styles/ source files - run: npm run build:css + - name: Build all generated files + run: npm run build - name: Deploy to GitHub Pages (gh-pages branch) uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml deleted file mode 100644 index 450d97b..0000000 --- a/.github/workflows/e2e-tests.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: E2E Tests - -on: - push: - branches: - - main - pull_request: - merge_group: - -permissions: - contents: read - -jobs: - changes: - runs-on: ubuntu-latest - outputs: - code: ${{ steps.filter.outputs.code }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Detect relevant file changes - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 - id: filter - with: - filters: | - code: - - '**/*.js' - - '**/*.ts' - - '**/*.css' - - 'tests/**' - - 'package*.json' - - test-e2e: - needs: changes - if: needs.changes.outputs.code == 'true' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Install Playwright browsers - run: npx playwright install --with-deps chromium - - - name: Build generated files - run: npm run build - - - name: Run E2E tests - run: npm run test:e2e - - e2e-test-status: - needs: [changes, test-e2e] - if: always() - runs-on: ubuntu-latest - steps: - - name: Report status - run: | - if [[ "${{ needs.test-e2e.result }}" == "failure" ]]; then - echo "E2E tests failed" - exit 1 - fi - echo "E2E tests passed or were skipped (no relevant files changed)" diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 1596bfb..1522793 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -12,17 +12,8 @@ jobs: preview: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup - name: Build all generated files run: npm run build @@ -35,8 +26,8 @@ jobs: destination_dir: previews/pr-${{ github.event.number }} # Preserve existing previews and production files keep_files: true - # Exclude non-site files - exclude_assets: '.github,node_modules,tests,scripts,package-lock.json,package.json,milestones.yaml' + # Exclude non-site files (keep in sync with deploy.yml) + exclude_assets: '.github,node_modules,tests,scripts,package-lock.json,package.json,milestones.yaml,project-stats.yaml' - name: Post or update preview URL comment uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/weekly-stats-check.yml b/.github/workflows/weekly-stats-check.yml new file mode 100644 index 0000000..3220fce --- /dev/null +++ b/.github/workflows/weekly-stats-check.yml @@ -0,0 +1,100 @@ +name: Weekly Project Stats Check + +# Opens a reminder issue when the recorded pr_count in project-stats.yaml +# is lower than the actual number of merged PRs in the repository. + +on: + schedule: + - cron: '0 9 * * 1' # Every Monday at 09:00 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + check-stats: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check project-stats.yaml against actual merged PR count + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs'); + + // Read the recorded pr_count from project-stats.yaml + const yaml = fs.readFileSync('project-stats.yaml', 'utf8'); + const match = yaml.match(/^pr_count:\s*(\d+)/m); + if (!match) { + throw new Error('Invalid or missing pr_count in project-stats.yaml'); + } + const recordedCount = parseInt(match[1], 10); + if (!Number.isInteger(recordedCount)) { + throw new Error('Invalid or missing pr_count in project-stats.yaml'); + } + + // Count actual merged PRs via the GitHub API + let page = 1; + let totalMerged = 0; + while (true) { + const { data } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'closed', + per_page: 100, + page, + }); + if (data.length === 0) break; + totalMerged += data.filter(pr => pr.merged_at !== null).length; + if (data.length < 100) break; + page++; + } + + console.log(`Recorded pr_count: ${recordedCount}, actual merged PRs: ${totalMerged}`); + + if (recordedCount >= totalMerged) { + console.log('project-stats.yaml is up to date — no action needed.'); + return; + } + + const title = `chore: update project-stats.yaml (recorded ${recordedCount}, actual ${totalMerged} merged PRs)`; + const body = [ + '## 📊 Project Stats Are Out of Date', + '', + `**Recorded \`pr_count\`:** ${recordedCount}`, + `**Actual merged PRs:** ${totalMerged}`, + `**Difference:** ${totalMerged - recordedCount} untracked PR(s)`, + '', + 'After the next agent session, update `project-stats.yaml`:', + '', + '```yaml', + `pr_count: ${totalMerged}`, + '```', + '', + 'Then run `npm run build:project-stats` to regenerate `project-stats-data.js`, or let the deploy workflow handle it.', + ].join('\n'); + + // Avoid duplicate open issues (paginate to cover all open items, exclude PRs) + const openIssues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + }); + const duplicate = openIssues.find(i => + i.title.startsWith('chore: update project-stats.yaml') && !i.pull_request + ); + if (duplicate) { + console.log(`Reminder issue already open: #${duplicate.number} — skipping.`); + return; + } + + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + }); + console.log(`Created reminder issue: #${issue.number}`); diff --git a/scripts/build-bundle.js b/scripts/build-bundle.js new file mode 100644 index 0000000..4b16c52 --- /dev/null +++ b/scripts/build-bundle.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * build-bundle.js + * + * Shared helper used by build-js.js and build-css.js. + * Concatenates an ordered list of source files, optionally wraps them with a + * header and footer string, then minifies the result with esbuild. + * + * This module is not a standalone script — import it via require(). + * + * @example + * const { buildBundle } = require('./build-bundle'); + * buildBundle({ parts, srcDir, outPath, loader: 'css' }); + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const esbuild = require('esbuild'); + +/** + * Build a minified bundle from an ordered list of source files. + * + * @param {object} opts + * @param {string[]} opts.parts - Ordered source file names + * @param {string} opts.srcDir - Directory that contains the source files + * @param {string} opts.outPath - Absolute path for the output file + * @param {'js'|'css'} opts.loader - esbuild loader type + * @param {string} [opts.header] - Text prepended before minification + * @param {string} [opts.footer] - Text appended before minification + * @param {object} [opts.esbuildOptions]- Extra options merged into the esbuild call + */ +function buildBundle(opts) { + const chunks = opts.parts.map((file) => { + const fullPath = path.join(opts.srcDir, file); + if (!fs.existsSync(fullPath)) { + throw new Error(`Missing source file: ${file}`); + } + return fs.readFileSync(fullPath, 'utf8'); + }); + + // Concatenate with explicit newline separators so files without trailing + // newlines don't accidentally merge tokens across boundaries. + let unminified = chunks.join('\n'); + if (opts.header) unminified = opts.header + '\n' + unminified; + if (opts.footer) unminified = unminified + '\n' + opts.footer; + + const esbuildOpts = Object.assign( + { minify: true, loader: opts.loader }, + opts.esbuildOptions || {}, + ); + + const result = esbuild.transformSync(unminified, esbuildOpts); + fs.writeFileSync(opts.outPath, result.code); + + const outName = path.basename(opts.outPath); + const ratio = unminified.length === 0 + ? '0.0' + : ((1 - result.code.length / unminified.length) * 100).toFixed(1); + console.log( + `${outName} rebuilt from ${opts.parts.length} source files ` + + `(${unminified ? unminified.split('\n').length : 0} lines → ${result.code.length} bytes, −${ratio}% via esbuild minification)`, + ); +} + +module.exports = { buildBundle }; diff --git a/scripts/build-css.js b/scripts/build-css.js index c68867a..2e82384 100644 --- a/scripts/build-css.js +++ b/scripts/build-css.js @@ -14,9 +14,8 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); -const esbuild = require('esbuild'); +const path = require('path'); +const { buildBundle } = require('./build-bundle'); const ROOT = path.resolve(__dirname, '..'); @@ -36,30 +35,9 @@ const PARTS = [ 'scary-features.css', ]; -const chunks = PARTS.map((file) => { - const fullPath = path.join(ROOT, 'styles', file); - if (!fs.existsSync(fullPath)) { - throw new Error(`Missing source file: styles/${file}`); - } - return fs.readFileSync(fullPath, 'utf8'); -}); - -// Concatenate directly — each source file preserves its own trailing blank lines -// so no additional separator is needed. -const unminified = chunks.join(''); - -const outPath = path.join(ROOT, 'styles.css'); - -// Minify with esbuild (synchronous transform API — no temp files needed). -const result = esbuild.transformSync(unminified, { - minify: true, +buildBundle({ + parts: PARTS, + srcDir: path.join(ROOT, 'styles'), + outPath: path.join(ROOT, 'styles.css'), loader: 'css', }); - -fs.writeFileSync(outPath, result.code); - -const ratio = ((1 - result.code.length / unminified.length) * 100).toFixed(1); -console.log( - `styles.css rebuilt from ${PARTS.length} source files ` + - `(${unminified.split('\n').length - 1} lines → ${result.code.length} bytes, −${ratio}% via esbuild minification)`, -); diff --git a/scripts/build-js.js b/scripts/build-js.js index 71551db..dd8ae90 100644 --- a/scripts/build-js.js +++ b/scripts/build-js.js @@ -15,9 +15,8 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); -const esbuild = require('esbuild'); +const path = require('path'); +const { buildBundle } = require('./build-bundle'); const ROOT = path.resolve(__dirname, '..'); @@ -59,38 +58,17 @@ const HEADER = [ '(function () {', ].join('\n'); -const FOOTER = '})();'; - -const chunks = PARTS.map((file) => { - const fullPath = path.join(ROOT, 'src', 'js', file); - if (!fs.existsSync(fullPath)) { - throw new Error(`Missing source file: src/js/${file}`); - } - return fs.readFileSync(fullPath, 'utf8'); -}); - -// Concatenate inner body directly — each source file preserves its own -// trailing blank lines so no additional separator is needed. -const innerBody = chunks.join(''); - -const unminified = HEADER + '\n' + innerBody + FOOTER + '\n'; - -const outPath = path.join(ROOT, 'script.js'); - -// Minify with esbuild (synchronous transform API — no temp files needed). -const result = esbuild.transformSync(unminified, { - minify: true, - // Preserve the leading banner comment so tools can still identify the file. - banner: '/* AI DEATH CLOCK — browser/DOM layer (minified) */', - // Target all modern browsers; no transpilation needed. - target: ['es2018'], - loader: 'js', +buildBundle({ + parts: PARTS, + srcDir: path.join(ROOT, 'src', 'js'), + outPath: path.join(ROOT, 'script.js'), + loader: 'js', + header: HEADER, + footer: '})();', + esbuildOptions: { + // Preserve the leading banner comment so tools can still identify the file. + banner: '/* AI DEATH CLOCK — browser/DOM layer (minified) */', + // Target all modern browsers; no transpilation needed. + target: ['es2018'], + }, }); - -fs.writeFileSync(outPath, result.code); - -const ratio = ((1 - result.code.length / unminified.length) * 100).toFixed(1); -console.log( - `script.js rebuilt from ${PARTS.length} source files ` + - `(${unminified.split('\n').length - 1} lines → ${result.code.length} bytes, −${ratio}% via esbuild minification)`, -);