diff --git a/.gitattributes b/.gitattributes index 3e9a73e..2bb0f3b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,4 @@ *.md text eol=lf *.toml text eol=lf *.yml text eol=lf +*.ttf binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 516f120..43ef165 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,3 +124,28 @@ jobs: working-directory: demo - run: npm run lint working-directory: demo + + release-screenshot: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.13' + - name: Install the hash-locked release-image renderer + run: python -m pip install --require-hashes -r tools/release-requirements.txt + - name: Run the screenshot tests and fail if any are skipped or missing + env: + PROOFCLI_REQUIRE_RENDER: '1' + run: python tests/test_release_screenshot.py + - name: Render the release screenshot + run: python tools/render_release_screenshot.py dist/proofcli-screenshot.png + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: release-screenshot + path: dist/proofcli-screenshot.png + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/demo-video.yml b/.github/workflows/demo-video.yml index f68e443..15ddebc 100644 --- a/.github/workflows/demo-video.yml +++ b/.github/workflows/demo-video.yml @@ -59,82 +59,3 @@ jobs: demo/out/proofcli-motion-v1-verification.json if-no-files-found: error retention-days: 7 - - publish: - if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - needs: render - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 - with: - name: motion-demos-v1 - path: motion-demos/ - - name: Add the new cut without replacing published package assets - env: - GH_TOKEN: ${{ github.token }} - run: | - tag="$(python - <<'PY' - import ast - import re - import sys - from pathlib import Path - - root = Path('.') - section = None - pyproject = None - for line in (root / 'pyproject.toml').read_text(encoding='utf-8').splitlines(): - stripped = line.strip() - if stripped.startswith('['): - section = stripped - elif section == '[project]': - match = re.fullmatch(r'version\s*=\s*"([^"]+)"', stripped) - if match: - pyproject = match.group(1) - if not pyproject: - sys.exit('pyproject.toml has no [project] version') - tree = ast.parse((root / 'proofcli' / '__init__.py').read_text(encoding='utf-8')) - module = None - for node in tree.body: - if isinstance(node, ast.Assign) and any( - getattr(target, 'id', None) == '__version__' for target in node.targets - ): - module = ast.literal_eval(node.value) - if module != pyproject: - sys.exit( - f'refusing to select a release: proofcli.__version__ {module!r} ' - f'!= pyproject {pyproject!r}' - ) - if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', pyproject): - sys.exit(f'refusing to select a release: unsupported version {pyproject!r}') - print(f'v{pyproject}') - PY - )" - echo "Selected release tag ${tag}" - if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName; then - echo "Refusing to upload: release ${tag} does not exist" >&2 - exit 1 - fi - gh release upload "$tag" motion-demos/* --repo "$GITHUB_REPOSITORY" - gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json body --jq .body > notes.md - TAG="$tag" python - <<'PY' - import os - from pathlib import Path - tag = os.environ['TAG'] - p = Path('notes.md') - body = p.read_text(encoding='utf-8') - if 'proofcli-instagram-reel-v1.mp4' not in body: - body += ( - '\n\n### 30-second motion demos\n\n' - f'[Vertical / Instagram](https://github.com/bk1202/proofcli/releases/download/{tag}/proofcli-instagram-reel-v1.mp4) · ' - f'[Landscape](https://github.com/bk1202/proofcli/releases/download/{tag}/proofcli-landscape-motion-v1.mp4)\n\n' - 'Both use results captured from the actual exporter and ProofCLI. Covers, checksums and media validation are attached separately.\n' - ) - p.write_text(body, encoding='utf-8') - PY - gh release edit "$tag" --repo "$GITHUB_REPOSITORY" --notes-file notes.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e47f11..43da328 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: contents: read publish: - needs: [validate, demo] + needs: [validate] runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -34,12 +34,12 @@ jobs: - run: python -m pip install --require-hashes -r requirements-build.txt - run: python -m build --no-isolation - run: python tools/verify_dist.py - - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 - with: - name: demo-video - path: dist/ - - name: Add the demo checksum - run: python -c "import hashlib; from pathlib import Path; p=Path('dist/proofcli-demo.mp4'); f=Path('dist/SHA256SUMS'); f.write_text(f.read_text()+hashlib.sha256(p.read_bytes()).hexdigest()+' '+p.name+'\n')" + - name: Install the release-image font renderer + run: python -m pip install --require-hashes -r tools/release-requirements.txt + - name: Render a screenshot from a real failing check and a real passing run + run: python tools/render_release_screenshot.py dist/proofcli-screenshot.png + - name: Add the screenshot checksum + run: python -c "import hashlib; from pathlib import Path; p=Path('dist/proofcli-screenshot.png'); f=Path('dist/SHA256SUMS'); f.write_text(f.read_text()+hashlib.sha256(p.read_bytes()).hexdigest()+' '+p.name+'\n')" - name: Prepare release notes from the versioned changelog env: RELEASE_TAG: ${{ github.ref_name }} @@ -48,39 +48,4 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} - run: gh release create "$RELEASE_TAG" dist/*.whl dist/*.tar.gz dist/*.mp4 dist/SHA256SUMS --verify-tag --title "ProofCLI $RELEASE_TAG" --notes-file dist/RELEASE_NOTES.md - - demo: - needs: validate - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: '22' - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - with: - python-version: '3.13' - - name: Capture actual exporter and CLI results - run: python tools/capture_demo.py - - name: Install locked demo build tools - working-directory: demo - run: npm ci - - name: Check demo dependencies - working-directory: demo - run: npm audit --audit-level=high - - name: Type-check the composition - working-directory: demo - run: npm run lint - - name: Render the 30-second walkthrough - working-directory: demo - run: npm run render - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f - with: - name: demo-video - path: demo/out/proofcli-demo.mp4 - if-no-files-found: error - retention-days: 7 + run: gh release create "$RELEASE_TAG" dist/*.whl dist/*.tar.gz dist/proofcli-screenshot.png dist/SHA256SUMS --verify-tag --title "ProofCLI $RELEASE_TAG" --notes-file dist/RELEASE_NOTES.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 448dfa7..67cda32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Changed - `proofcli --help` lists `run`, `validate`, and `init`, each with its own one-line description. `proofcli run --help`, `proofcli validate --help`, and `proofcli init --help` each describe that command instead of sharing one sentence. `proofcli SPEC` still runs a spec, and exit codes are unchanged. +- GitHub releases now attach one PNG of real ProofCLI output: the failing export check and the passing run. The image is drawn with a vendored monospace font. Pillow is installed only while that release image is built, and ProofCLI itself still has no runtime dependencies. The release workflow no longer renders a demo video or installs the Remotion tools, so a video render cannot block the GitHub release or the PyPI publish that follows it. The motion-demo workflow can still render preview videos as workflow artifacts, and it no longer uploads those videos to a release. +- Every pull request and push runs a `release-screenshot` check on Python 3.13. That job installs only the hash-locked Pillow used for the release image, runs the screenshot tests, and fails if one of those tests is skipped or missing. It also uploads the PNG as a workflow artifact. ## 0.4.2 — 2026-10-16 diff --git a/docs/demo.md b/docs/demo.md index f1dd293..b5c14d8 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -27,6 +27,6 @@ npm run poster The output is `demo/out/proofcli-demo.mp4`; the poster is `demo/out/demo-poster.png`. Dependencies are locked in `demo/package-lock.json` and are used only to build the video. The Python package and GitHub Action do not install them. -`demo-video.yml` renders pull-request previews and publishes the named revision after merge to main. It refuses to replace an existing asset. Future edits must use a new revision filename and matching links instead of overwriting published bytes. This media workflow does not trigger a Python package release. +`demo-video.yml` can still render preview videos and store them as workflow artifacts. It does not upload those videos to a GitHub release, and it does not trigger a Python package release. [The hosted demo workflow](../.github/workflows/demo.yml) intentionally has a successful ordinary job and a failed ProofCLI job. Its failed overall result is the expected demonstration. The separate validation workflow asserts that same failure and remains green only when ProofCLI rejects the broken export. diff --git a/docs/releasing.md b/docs/releasing.md index e0c1704..c2b2d89 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -4,7 +4,7 @@ 2. Install the hash-locked build tools, build wheel/sdist, run `tools/verify_dist.py`, then run the real-process suite against the installed wheel. `tools/audit_dependencies.py` checks build versions/hashes against PyPI's advisory feed; no runtime dependencies are declared. 3. Only after local validation, publish the candidate branch and obtain green Windows/macOS tests, both distribution-install jobs, and the advisory job. Inspect skips/failures. Merge the same reviewed candidate into main; retain CI links as evidence. 4. Create and push an annotated `v` tag targeting the validated commit. A pre-1.0 release is not a production/security certification. -5. The tag triggers `release.yml`. The read-only validation matrix must pass, including Linux runtime and three-platform composite Action checks. A separate job captures real demo evidence and renders the 30-second video with locked npm tools. The publish job checks tag/version agreement, builds with pinned tools, verifies fresh wheel/sdist installs, and creates the GitHub release with both distributions, MP4, and `SHA256SUMS`. +5. The tag triggers `release.yml`. The read-only validation matrix must pass, including Linux runtime and three-platform composite Action checks. The publish job checks tag/version agreement, builds with pinned tools, verifies fresh wheel/sdist installs, installs hash-locked Pillow from `tools/release-requirements.txt` only to draw one PNG from a real failing run and a real passing run, and creates the GitHub release with both distributions, that screenshot, and `SHA256SUMS`. Pillow is not a ProofCLI runtime dependency. The job does not render a demo video. 6. Successful release completion triggers `pypi.yml` on the default branch. Its read-only job accepts only a successful tag-push release from this repository, checks that the tag points to the validated commit, and downloads/checksums the published distribution bytes. A separate `pypi` environment job uses PyPA Trusted Publishing with `id-token: write` to upload those exact bytes. It executes no repository code and stores no long-lived token. 7. Confirm both workflows, actual downloadable checksums, PyPI version metadata, and a fresh `pip install proofcli==` before announcing index availability. Release creation and PyPI publication do not overwrite published versions; inspect partial failures rather than silently replacing bytes. diff --git a/tests/test_release_screenshot.py b/tests/test_release_screenshot.py new file mode 100644 index 0000000..049bbfc --- /dev/null +++ b/tests/test_release_screenshot.py @@ -0,0 +1,191 @@ +"""The release screenshot is a PNG of a real failing run and a real passing run.""" + +import os +from pathlib import Path +import struct +import runpy +import sys +import unittest + +PROJECT = Path(__file__).resolve().parents[1] +tool = runpy.run_path(str(PROJECT / "tools" / "render_release_screenshot.py")) + +# Every test in this module. The release-screenshot job fails unless each one ran. +EXPECTED_TESTS = ( + "test_real_runs_keep_their_output_text", + "test_png_is_deterministic_and_readable", + "test_unknown_character_is_not_replaced", + "test_require_render_refuses_to_skip", + "test_missing_pillow_skips_only_without_require_render", + "test_render_guard_rejects_skips_and_missing_tests", +) + + +def _pillow(): + try: + import PIL # noqa: F401 + except ImportError: + return False + return True + + +def _require_render(): + return os.environ.get("PROOFCLI_REQUIRE_RENDER") == "1" + + +def demand_pillow(test, available): + """Skip the drawing test, or fail it when the release job requires a render.""" + if available: + return + message = "Pillow is required to draw the release screenshot" + if _require_render(): + test.fail(message) + test.skipTest("Pillow is installed only when building the release image") + + +def _method_name(test): + return test.id().rsplit(".", 1)[-1] + + +def evaluate_render_run(expected, executed, skipped): + """Return problems. `executed` and `skipped` are method names.""" + expected = tuple(expected) + problems = [] + if len(executed) != len(expected) or set(executed) != set(expected): + problems.append( + f"ran {len(executed)} {sorted(executed)} != expected {len(expected)} {list(expected)}" + ) + if skipped: + problems.append(f"skipped {len(skipped)} {sorted(skipped)}") + return problems + + +def _chunks(data): + assert data.startswith(b"\x89PNG\r\n\x1a\n") + pos = 8 + tags = [] + while pos < len(data): + length = struct.unpack(">I", data[pos:pos + 4])[0] + tags.append(data[pos + 4:pos + 8]) + pos += 12 + length + return tags + + +def _size(data): + return struct.unpack(">II", data[16:24]) + + +class ReleaseScreenshotTests(unittest.TestCase): + def test_real_runs_keep_their_output_text(self): + blocks = tool["capture"]() + self.assertIn("python -m proofcli run examples/export/wrong-value.json", blocks[0][0]) + self.assertIn("process exit: 1", blocks[0][0]) + self.assertIn("999.00", blocks[0][1]) + self.assertIn("FAILED", blocks[0][1]) + self.assertIn("expected: \"8.25\"", blocks[0][1]) + self.assertIn("python -m proofcli run examples/export/good.json", blocks[1][0]) + self.assertIn("process exit: 0", blocks[1][0]) + self.assertIn("All 9 checks passed.", blocks[1][1]) + self.assertIn("PASSED", blocks[1][1]) + + def test_png_is_deterministic_and_readable(self): + demand_pillow(self, _pillow()) + blocks = tool["capture"]() + first = tool["render_png"](blocks) + second = tool["render_png"](blocks) + self.assertEqual(first, second) + width, height = _size(first) + self.assertGreaterEqual(width, 1200) + self.assertLessEqual(width, 1600) + self.assertGreater(height, 600) + tags = _chunks(first) + for banned in (b"tIME", b"tEXt", b"iTXt", b"zTXt"): + self.assertNotIn(banned, tags) + self.assertEqual(tags[0], b"IHDR") + self.assertEqual(tags[-1], b"IEND") + + def test_unknown_character_is_not_replaced(self): + with self.assertRaises(ValueError): + tool["render_png"]([("ok", "caf\u00e9")]) + + def test_require_render_refuses_to_skip(self): + previous = os.environ.get("PROOFCLI_REQUIRE_RENDER") + os.environ["PROOFCLI_REQUIRE_RENDER"] = "1" + try: + with self.assertRaises(AssertionError): + demand_pillow(self, False) + finally: + if previous is None: + os.environ.pop("PROOFCLI_REQUIRE_RENDER", None) + else: + os.environ["PROOFCLI_REQUIRE_RENDER"] = previous + + def test_missing_pillow_skips_only_without_require_render(self): + previous = os.environ.pop("PROOFCLI_REQUIRE_RENDER", None) + try: + with self.assertRaises(unittest.SkipTest): + demand_pillow(self, False) + finally: + if previous is not None: + os.environ["PROOFCLI_REQUIRE_RENDER"] = previous + + def test_render_guard_rejects_skips_and_missing_tests(self): + self.assertEqual(evaluate_render_run(EXPECTED_TESTS, EXPECTED_TESTS, []), []) + problems = evaluate_render_run( + EXPECTED_TESTS, + ["test_png_is_deterministic_and_readable"], + ["test_real_runs_keep_their_output_text"], + ) + self.assertEqual(len(problems), 2) + + +class _RecordingResult(unittest.TextTestResult): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.executed = [] + self.skipped_names = [] + + def addSuccess(self, test): + super().addSuccess(test) + self.executed.append(_method_name(test)) + + def addFailure(self, test, err): + super().addFailure(test, err) + self.executed.append(_method_name(test)) + + def addError(self, test, err): + super().addError(test, err) + self.executed.append(_method_name(test)) + + def addSkip(self, test, reason): + super().addSkip(test, reason) + self.skipped_names.append(_method_name(test)) + + +def run_required(): + """Run this module and fail unless every expected test ran and none were skipped.""" + suite = unittest.defaultTestLoader.loadTestsFromTestCase(ReleaseScreenshotTests) + found = [_method_name(test) for test in suite] + if set(found) != set(EXPECTED_TESTS) or len(found) != len(EXPECTED_TESTS): + print("Release screenshot tests do not match the expected ids:", file=sys.stderr) + print(f" found {found}", file=sys.stderr) + print(f" expected {list(EXPECTED_TESTS)}", file=sys.stderr) + return 1 + runner = unittest.TextTestRunner(verbosity=2, resultclass=_RecordingResult) + result = runner.run(suite) + problems = evaluate_render_run(EXPECTED_TESTS, result.executed, result.skipped_names) + print( + f"release-screenshot tests: ran {len(result.executed)}, " + f"skipped {len(result.skipped_names)}, expected {len(EXPECTED_TESTS)}" + ) + if problems or not result.wasSuccessful(): + for problem in problems: + print(problem, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + if _require_render(): + sys.exit(run_required()) + unittest.main() diff --git a/tools/fonts/AUTHORS.txt b/tools/fonts/AUTHORS.txt new file mode 100644 index 0000000..8814941 --- /dev/null +++ b/tools/fonts/AUTHORS.txt @@ -0,0 +1,10 @@ +# This is the official list of project authors for copyright purposes. +# This file is distinct from the CONTRIBUTORS.txt file. +# See the latter for an explanation. +# +# Names should be added to this file as: +# Name or Organization + +JetBrains <> +Philipp Nurullin +Konstantin Bulenkov diff --git a/tools/fonts/JetBrainsMonoNL-Bold.ttf b/tools/fonts/JetBrainsMonoNL-Bold.ttf new file mode 100644 index 0000000..f78f84f Binary files /dev/null and b/tools/fonts/JetBrainsMonoNL-Bold.ttf differ diff --git a/tools/fonts/JetBrainsMonoNL-Regular.ttf b/tools/fonts/JetBrainsMonoNL-Regular.ttf new file mode 100644 index 0000000..70d2ec9 Binary files /dev/null and b/tools/fonts/JetBrainsMonoNL-Regular.ttf differ diff --git a/tools/fonts/OFL.txt b/tools/fonts/OFL.txt new file mode 100644 index 0000000..8bee414 --- /dev/null +++ b/tools/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/tools/prepare_release.py b/tools/prepare_release.py index d7c4d84..9614ed8 100644 --- a/tools/prepare_release.py +++ b/tools/prepare_release.py @@ -21,7 +21,7 @@ def main(): run = os.environ.get("GITHUB_RUN_ID") if run: notes += f"Validation workflow: [runtime, Action, distribution and advisory checks](https://github.com/{repo}/actions/runs/{run}).\n\n" - notes += f"[Watch the 30-second demo](https://github.com/{repo}/releases/download/{tag}/proofcli-demo.mp4). Requires Python 3.10+. Assets contain a universal Python wheel, source distribution, demo video and SHA256SUMS. PyPI publication runs separately after this release; check its workflow before assuming index availability. Commands run with your user permissions, and a pass covers only the declared assertions. See SECURITY.md and docs/audit-0.3.md for remaining limits.\n" + notes += f"[Screenshot of a failing check and a passing run](https://github.com/{repo}/releases/download/{tag}/proofcli-screenshot.png). Requires Python 3.10+. Assets contain a universal Python wheel, source distribution, that screenshot, and SHA256SUMS. PyPI publication runs separately after this release; check its workflow before assuming index availability. Commands run with your user permissions, and a pass covers only the declared assertions. See SECURITY.md and docs/audit-0.3.md for remaining limits.\n" Path("dist/RELEASE_NOTES.md").write_text(notes, encoding="utf-8") diff --git a/tools/release-requirements.txt b/tools/release-requirements.txt new file mode 100644 index 0000000..f913670 --- /dev/null +++ b/tools/release-requirements.txt @@ -0,0 +1,5 @@ +# Release-image only. ProofCLI declares no runtime dependencies. +# Hashes checked against the Pillow 12.3.0 files published on PyPI. +pillow==12.3.0 \ + --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ + --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 diff --git a/tools/render_release_screenshot.py b/tools/render_release_screenshot.py new file mode 100644 index 0000000..10c1176 --- /dev/null +++ b/tools/render_release_screenshot.py @@ -0,0 +1,169 @@ +"""Draw a release PNG from a real failing run and a real passing run. + +The command output is painted unchanged. Pillow and the vendored JetBrains Mono +(no ligatures) are used only for this image. ProofCLI itself has no runtime +dependencies. Elapsed time and the temporary directory come from the process +and are not rewritten. The PNG has no text or time metadata. +""" + +import io +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FONT_DIR = ROOT / "tools" / "fonts" +REGULAR = FONT_DIR / "JetBrainsMonoNL-Regular.ttf" +BOLD = FONT_DIR / "JetBrainsMonoNL-Bold.ttf" +ASSET_NAME = "proofcli-screenshot.png" +FAILING = "examples/export/wrong-value.json" +PASSING = "examples/export/good.json" + +# Logical sizes are doubled so the file is a crisp 2x image. +SCALE = 2 +BODY = 20 * SCALE +LABEL = 26 * SCALE +LINE = 32 * SCALE +PAD = 32 * SCALE +BAR = 8 * SCALE +GAP = 16 * SCALE +MIN_WIDTH = 640 * SCALE + +BG = (18, 20, 16) +PANEL = (28, 31, 26) +FG = (236, 235, 227) +LIME = (213, 255, 63) +MUTED = (150, 153, 141) +FAIL = (255, 91, 59) +PASS_COLOR = (61, 220, 151) +RULE = (65, 68, 58) + + +def _require_text(text): + for char in text: + if char == "\n": + continue + if ord(char) < 32 or ord(char) > 126: + raise ValueError(f"refusing to omit unsupported character {char!r} from the screenshot") + + +def _fonts(): + from PIL import ImageFont + regular = ImageFont.truetype(str(REGULAR), BODY) + label = ImageFont.truetype(str(BOLD), LABEL) + return regular, label + + +def _width(font, text): + if not text: + return 0 + return int(font.getlength(text) + 0.999) + + +def render_png(blocks): + """Return PNG bytes. `blocks` are (heading, stdout) from `capture`.""" + for heading, body in blocks: + _require_text(heading) + _require_text(body) + try: + from PIL import Image, ImageDraw + except ImportError as exc: + raise RuntimeError("Pillow is required to draw the release screenshot") from exc + regular, label = _fonts() + sections = [] + content_width = 0 + for index, (heading, body) in enumerate(blocks): + accent = FAIL if index == 0 else PASS_COLOR + title = "FAILED" if index == 0 else "PASSED" + content_width = max(content_width, _width(label, title)) + heading_lines = heading.splitlines() or [""] + body_lines = body.splitlines() or [""] + for line in heading_lines + body_lines: + content_width = max(content_width, _width(regular, line)) + sections.append((accent, title, heading_lines, body_lines)) + width = max(MIN_WIDTH, content_width + PAD * 2 + BAR + PAD // 2) + text_x = BAR + PAD + rows = [] + y = PAD + for accent, title, heading_lines, body_lines in sections: + top = y + rows.append(("text", text_x, y, title, label, accent)) + y += LABEL + GAP + for number, line in enumerate(heading_lines): + color = LIME if number == 0 else MUTED + rows.append(("text", text_x, y, line, regular, color)) + y += LINE + y += GAP // 2 + rows.append(("rule", text_x, y, width - PAD)) + y += GAP + panel_top = y - GAP // 3 + panel_lines = body_lines + panel_bottom = y + LINE * len(panel_lines) + GAP // 3 + rows.append(("panel", BAR, panel_top, width, panel_bottom, accent)) + for line in panel_lines: + rows.append(("text", text_x, y, line, regular, FG)) + y += LINE + y = panel_bottom + GAP + rows.append(("bar", 0, top, BAR, y - GAP // 2, accent)) + y += GAP + height = y + image = Image.new("RGB", (width, height), BG) + draw = ImageDraw.Draw(image) + # Panels and bars first, then text, so antialiased glyphs stay on top. + for kind, *rest in rows: + if kind == "panel": + left, top, right, bottom, accent = rest + draw.rectangle((left, top, right - 1, bottom - 1), fill=PANEL) + elif kind == "bar": + left, top, bar_w, bottom, accent = rest + draw.rectangle((left, top, left + bar_w - 1, bottom - 1), fill=accent) + elif kind == "rule": + x0, rule_y, x1 = rest + draw.rectangle((x0, rule_y, x1, rule_y + SCALE), fill=RULE) + for kind, *rest in rows: + if kind != "text": + continue + x, text_y, text, font, color = rest + draw.text((x, text_y), text, font=font, fill=color, anchor="la") + buffer = io.BytesIO() + image.save(buffer, format="PNG", compress_level=9) + return buffer.getvalue() + + +def run_spec(spec): + completed = subprocess.run( + [sys.executable, "-m", "proofcli", "run", spec], + cwd=ROOT, + capture_output=True, + text=True, + timeout=60, + ) + if completed.stderr: + raise RuntimeError(f"{spec} wrote stderr, which this screenshot does not expect:\n{completed.stderr}") + return completed.returncode, completed.stdout + + +def capture(): + """Run both examples. Fail closed if they do not actually fail and pass.""" + fail_code, fail_out = run_spec(FAILING) + pass_code, pass_out = run_spec(PASSING) + if fail_code != 1 or "FAILED" not in fail_out or "999.00" not in fail_out: + raise RuntimeError(f"failing example did not produce the real mismatch (exit {fail_code})") + if pass_code != 0 or "PASSED" not in pass_out or "All 9 checks passed." not in pass_out: + raise RuntimeError(f"passing example did not pass (exit {pass_code})") + fail_heading = f"python -m proofcli run {FAILING}\nprocess exit: {fail_code}" + pass_heading = f"python -m proofcli run {PASSING}\nprocess exit: {pass_code}" + return [(fail_heading, fail_out), (pass_heading, pass_out)] + + +def main(argv=None): + argv = sys.argv[1:] if argv is None else argv + destination = Path(argv[0]) if argv else ROOT / "dist" / ASSET_NAME + blocks = capture() + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(render_png(blocks)) + print(destination) + + +if __name__ == "__main__": + main()