Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand 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:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/infrastructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
46 changes: 46 additions & 0 deletions tools/site_storage.py
Original file line number Diff line number Diff line change
@@ -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()
63 changes: 63 additions & 0 deletions tools/site_storage_test.py
Original file line number Diff line number Diff line change
@@ -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()
Loading