diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 2956d92..3345513 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -78,8 +78,10 @@ jobs: working-directory: source run: | set -euo pipefail - git fetch origin - if git show-ref --verify --quiet refs/remotes/origin/coverage-pages; then + retained_ref="$(git ls-remote --heads origin refs/heads/coverage-pages)" + if [[ -n "${retained_ref}" ]]; then + git fetch --no-tags --depth=1 origin \ + +refs/heads/coverage-pages:refs/remotes/origin/coverage-pages git worktree add ../site -B coverage-pages origin/coverage-pages else git worktree add --detach ../site @@ -118,6 +120,7 @@ jobs: RELEASE_TAG: ${{ steps.release.outputs.tag }} run: | set -euo pipefail + python3 source/tools/site_storage.py site git -C site config user.name 'github-actions[bot]' git -C site config user.email '41898282+github-actions[bot]@users.noreply.github.com' git -C site add --all @@ -128,6 +131,7 @@ jobs: mkdir -p public rsync --archive --exclude='.git' site/ public/ python3 source/tools/site_artwork.py source/docs/assets public + python3 source/tools/site_storage.py public - uses: actions/configure-pages@v6 - uses: actions/upload-pages-artifact@v5 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf3d95..833cd4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # 0.6.2 +- Fetch shallow publication snapshots and report complete site size with a 250 MB advisory budget. + # 0.6.1 * Transferred the canonical repository from `helly25/bashtest` to diff --git a/docs/infrastructure.md b/docs/infrastructure.md index 0953b4e..c29fb21 100644 --- a/docs/infrastructure.md +++ b/docs/infrastructure.md @@ -39,6 +39,20 @@ be retried independently; its failure does not require recreating a release or m Release helpers, numeric tags, and version agreement keep their existing contracts. No module dependency versions change in this rollout. +### Published-site budget + +Each publisher fetches only the latest `coverage-pages` snapshot with `--depth=1` and no tags. +`storage-report.json` measures the complete retained tree before committing and the staged tree +again after deployment-only artwork. It records file counts and bytes by top-level directory, +excluding Git metadata and the report itself. Release pages and assets are not changed. + +Publication warns at **250 MB**. An emergency **9 GB** payload guard leaves TAR packaging headroom; +GitHub's [supported published-site limit](https://docs.github.com/en/pages/getting-started-with-github-pages/github-pages-limits) +remains **1 GB**, so the emergency guard is not an operating target. Review growth before reaching +that limit. This repository does not currently publish coverage, so no coverage retention or +browser loader is introduced. Git history, build caches, and Actions artifacts are separate budgets. +The change follows [mbo PR #549](https://github.com/mboworks/mbo/pull/549). + ## Contributor rules and verification `AGENTS.md`, `GIT_RULES.md`, and `STYLE_SH.md` synchronize applicable shared rules. `RULES.md` diff --git a/tools/site_storage.py b/tools/site_storage.py new file mode 100644 index 0000000..cbd2776 --- /dev/null +++ b/tools/site_storage.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +"""Measure the complete publication tree without changing retained releases.""" + +import argparse +import json +from pathlib import Path + + +def sizes(root): + result = {} + for path in root.rglob('*'): + relative = path.relative_to(root) + if '.git' in relative.parts or relative.as_posix() == 'storage-report.json' or not path.is_file(): + continue + section = relative.parts[0] if len(relative.parts) > 1 else 'root' + stats = result.setdefault(section, {'files': 0, 'bytes': 0}) + stats['files'] += 1 + stats['bytes'] += path.stat().st_size + return result + + +def report(root): + result = sizes(root) + total = sum(entry['bytes'] for entry in result.values()) + report = {'schema': 1, 'total_bytes': total, 'sections': result, + 'review_bytes': 250_000_000} + (root / 'storage-report.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2), flush=True) + if total >= report['review_bytes']: + print('::warning::Published site reached 250 MB; review storage-report.json and docs/infrastructure.md.') + if total > 9_000_000_000: + raise ValueError('Published site exceeds the Pages deployment ceiling') + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('root', type=Path) + args = parser.parse_args() + report(args.root) + + +if __name__ == '__main__': + main() diff --git a/tools/site_storage_test.py b/tools/site_storage_test.py new file mode 100644 index 0000000..001a406 --- /dev/null +++ b/tools/site_storage_test.py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +"""Publication accounting preserves every release and measures both trees.""" + +import contextlib +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +import site_storage + + +class SiteStorageTest(unittest.TestCase): + def test_complete_tree_accounting_preserves_releases_and_is_repeatable(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + files = {'site/tag/1.2.3/index.html': b'release page', 'schema/v1.json': b'{}', + 'favicon.ico': b'icon', '.git/objects/private': b'not deployed'} + for name, data in files.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + with contextlib.redirect_stdout(io.StringIO()): + result = site_storage.report(root) + again = site_storage.report(root) + self.assertEqual(result, again) + self.assertEqual(result['total_bytes'], 18) + self.assertEqual(result['sections']['site'], {'files': 1, 'bytes': 12}) + self.assertNotIn('.git', result['sections']) + self.assertEqual(json.loads((root / 'storage-report.json').read_text()), result) + for name, data in files.items(): + self.assertEqual((root / name).read_bytes(), data) + + def test_advisory_and_emergency_payload_boundaries(self): + with tempfile.TemporaryDirectory() as directory: + for size, warns, fails in ((249_999_999, False, False), (250_000_000, True, False), + (9_000_000_000, True, False), (9_000_000_001, True, True)): + with self.subTest(size=size), mock.patch.object( + site_storage, 'sizes', return_value={'site': {'files': 1, 'bytes': size}}): + output = io.StringIO() + with contextlib.redirect_stdout(output): + if fails: + with self.assertRaisesRegex(ValueError, 'deployment ceiling'): + site_storage.report(Path(directory)) + else: + site_storage.report(Path(directory)) + self.assertEqual('::warning::' in output.getvalue(), warns) + + def test_publisher_measures_before_commit_and_after_artwork_with_shallow_fetch(self): + source = (Path(__file__).resolve().parent.parent / '.github/workflows/pages.yml').read_text() + self.assertLess(source.index('site_storage.py site'), source.index('git -C site commit')) + self.assertLess(source.index('site_artwork.py'), source.index('site_storage.py public')) + self.assertLess(source.index('site_storage.py public'), source.index('uses: actions/upload-pages-artifact@')) + self.assertIn('git fetch --no-tags --depth=1 origin', source) + self.assertIn('git ls-remote --heads origin refs/heads/coverage-pages', source) + self.assertNotIn('git fetch origin\n', source) + + +if __name__ == '__main__': + unittest.main()