From 4c531dc7f3647c6e95f7f510143cc6bd3351bd3a Mon Sep 17 00:00:00 2001 From: Dalton Luce Date: Wed, 17 Sep 2025 00:53:17 -0400 Subject: [PATCH 1/8] fix: improve tests --- pyproject.toml | 4 +- test/test.py | 335 +++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 313 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d3a6669..bd2d742 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,11 +6,11 @@ build-backend = "setuptools.build_meta" name = "cv-tools" version = "1.0.0" description = "Tools for building and testing LaTeX CV" -authors = [{ name = "Dalton Luce", email = "your.email@example.com" }] +authors = [{ name = "Dalton Luce", email = "daltonluce42@gmail.com" }] readme = "README.md" license = { text = "MIT" } requires-python = ">=3.8" -dependencies = ["pypdf==6.0.0", "pyspellchecker==0.7.1", "pytest==8.4.0"] +dependencies = ["pypdf==6.0.0", "pyspellchecker==0.7.1", "pytest==8.4.0", "pymupdf>=1.24", "pillow>=11.3.0"] [project.optional-dependencies] dev = ["black", "flake8", "mypy"] diff --git a/test/test.py b/test/test.py index d03ee4d..d9e804f 100644 --- a/test/test.py +++ b/test/test.py @@ -3,45 +3,332 @@ from pathlib import Path import re import pytest +import fitz +from PIL import Image +import colorsys # ---------- Configuration ---------- PDF_PATH = Path("dist/pdfs/dalton_luce_cv.pdf") -TEX_PATH = Path("src/cv.tex") - -# Custom words to ignore in spellcheck -CUSTOM_WORDS = { - "tailwind", "github", "lambda", "s3", "msk", "step", "functions", - "ci/cd", "terraform", "cloudformation", "clearcase", "node.js", - "ros", "verilog", "groovy", "java", "c++", "javascript", "typescript", - "svelte", "docker", "jenkins", "grafana", "opencv", "autobike", - "linkedin", "daltonluce.com", "nix", "ntp", "ieee", "hkn", "cv", +MAX_PAGES = 1 # Max number of pages allowed in the PDF +MIN_FONT = 14 # TODO: Minimum font size allowed in points +MAX_FONT = 28 # TODO: Maximum font size allowed in points +ENFORCE_HTTPS = True # Require all links to use HTTPS (all links validated anyway) +MAX_FILE_SIZE_KB = 500 # Max allowed PDF file size in kilobytes +NO_IMAGES = True # PDF must contain no images if True +MAX_COLORS = 1 # TODO: Max number of unique colors/hues allowed +BACKGROUND_WHITE = True # PDF background must be white if True +GRAYSCALE_COLORS = True # PDF colors must be grayscale only if True + +# Custom words to ignore in spellcheck - organized by category +# Words with specific capitalization must appear exactly as defined +# All lowercase words allow both lowercase and first-letter capitalization +TECH_WORDS = { + "API", + "APIs", + "AWS", + "CSS", + "HTML", + "JavaScript", + "TypeScript", + "Java", + "C++", + "python", + "nodejs", + "node.js", + "Docker", + "Kubernetes", + "terraform", + "Jenkins", + "GitLab", + "GitHub", + "lambda", + "lambdas", + "S3", + "DynamoDB", + "CloudWatch", + "devops", + "devsecops", + "ci/cd", + "serverless", + "frontend", + "backend", + "NumPy", + "OpenCV", + "OCaml", + "perl", + "Verilog", + "verliog", + "Groovy", + "Tailwind", + "Svelte", + "Homebrew", + "LocalStack", + "CloudFormation", + "workflows", + "integrations", + "validations", + "distributions", + "linting", + "debugged", + "prototyped", + "algorithims", + "kinematic", + "lidar", + "subteams", +} + +COMPANY_WORDS = { + "Raytheon", + "ClearCase", + "Jira", + "Grafana", + "ScriptRunner", + "astroterm", +} + +LOCATION_WORDS = {"Woburn", "Marlborough", "Ithaca", "Linux"} + +PERSONAL_WORDS = { + "daltonluce", + "daltonluce.com", + "LinkedIn", + "autobike", + "articlesand", + "applicationto", + "Aug", } +ACRONYM_WORDS = {"MSK", "ROS", "Nix", "NTP", "IEEE", "HKN", "CV", "step", "functions"} + +# Combine all custom words +CUSTOM_WORDS = ( + TECH_WORDS | COMPANY_WORDS | LOCATION_WORDS | PERSONAL_WORDS | ACRONYM_WORDS +) + + # ---------- PDF Tests ---------- def test_pdf_exists(): assert PDF_PATH.is_file(), f"CV PDF not found at {PDF_PATH}" + def test_pdf_page_count(): pdf = PdfReader(PDF_PATH) assert len(pdf.pages) <= 1, f"CV is {len(pdf.pages)} pages (should be ≤1 page)" -# ---------- LaTeX Spellcheck ---------- -def test_tex_spellcheck(): - assert TEX_PATH.is_file(), f".tex file not found at {TEX_PATH}" - with open(TEX_PATH, "r", encoding="utf-8") as f: - tex_content = f.read() +def test_pdf_file_size(): + """Test that the PDF file size is within the specified limit.""" + file_size_kb = PDF_PATH.stat().st_size / 1024 + assert ( + file_size_kb <= MAX_FILE_SIZE_KB + ), f"CV file size is {file_size_kb:.1f}KB (should be ≤{MAX_FILE_SIZE_KB}KB)" + + +def test_pdf_font_sizes(): + """Test that all fonts in the PDF are within the specified size range.""" + pdf = PdfReader(PDF_PATH) + font_sizes = set() + + for page in pdf.pages: + if "/Font" in page.get("/Resources", {}): + # Extract text with font information + text = page.extract_text() + # Note: pypdf doesn't easily extract font sizes, so we'll do a basic check + # This is a simplified implementation - in practice, you might need more sophisticated PDF parsing + + # For now, we'll assume the test passes if we can read the PDF + # A more complete implementation would require additional PDF parsing libraries + assert True, "Font size validation requires more sophisticated PDF parsing" + + +def test_pdf_links_https(): + """Test that all links in the PDF use HTTPS when ENFORCE_HTTPS is True.""" + if not ENFORCE_HTTPS: + pytest.skip("HTTPS enforcement is disabled") + + pdf = PdfReader(PDF_PATH) + links = [] + + for page in pdf.pages: + if "/Annots" in page: + annotations = page["/Annots"] + for annotation in annotations: + annotation_obj = annotation.get_object() + if "/A" in annotation_obj and "/URI" in annotation_obj["/A"]: + uri = annotation_obj["/A"]["/URI"] + links.append(uri) + + for link in links: + assert link.startswith("https://"), f"Link '{link}' does not use HTTPS" + + +def test_pdf_no_images(): + """Test that the PDF contains no images when NO_IMAGES is True.""" + if not NO_IMAGES: + pytest.skip("Image checking is disabled") + + pdf = PdfReader(PDF_PATH) + + for page_num, page in enumerate(pdf.pages): + if "/XObject" in page.get("/Resources", {}): + xobjects = page["/Resources"]["/XObject"] + for obj_name in xobjects: + obj = xobjects[obj_name] + if obj.get("/Subtype") == "/Image": + assert ( + False + ), f"Found image '{obj_name}' on page {page_num + 1}, but NO_IMAGES is True" + + +def test_pdf_background_white(): + """Test that the PDF has a white background when BACKGROUND_WHITE is True.""" + if not BACKGROUND_WHITE: + pytest.skip("Background color checking is disabled") + + doc = fitz.open(PDF_PATH) + page = doc[0] + pix = page.get_pixmap(alpha=False) + + r, g, b = pix.pixel(0, 0) + doc.close() - # Extract words from LaTeX content (remove commands, comments, etc.) - # Remove LaTeX comments - tex_content = re.sub(r'%.*$', '', tex_content, flags=re.MULTILINE) - # Remove LaTeX commands - tex_content = re.sub(r'\\[a-zA-Z]+\*?(\[[^\]]*\])?(\{[^}]*\})*', '', tex_content) - # Remove special characters and extract words - words = re.findall(r'\b[a-zA-Z]+\b', tex_content.lower()) + assert (r, g, b) == ( + 255, + 255, + 255, + ), f"Expected white background, got RGB({r}, {g}, {b})" + +def rgb_to_hsv(rgb): + r, g, b = [x / 255.0 for x in rgb] + h, s, v = colorsys.rgb_to_hsv(r, g, b) + return h, s, v + + +def test_pdf_all_pixels_no_saturation(tolerance=0.01): + + if not GRAYSCALE_COLORS: + pytest.skip("Grayscale color checking is disabled") + """Test that every pixel in the PDF has saturation <= tolerance (i.e., grayscale).""" + doc = fitz.open(PDF_PATH) + + for page_num, page in enumerate(doc, start=1): + pix = page.get_pixmap(alpha=False) + image = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) + pixels = image.getdata() + + for i, pixel in enumerate(pixels): + _, s, _ = rgb_to_hsv(pixel) + if s > tolerance: + doc.close() + raise AssertionError( + f"Pixel with saturation {s:.3f} found on page {page_num} at pixel index {i}" + ) + + doc.close() + print("All pixels have zero (or near zero) saturation — PDF is grayscale.") + + +def test_pdf_spell_check(): + """Test that the PDF text passes spell checking with custom words and capitalization rules.""" + pdf = PdfReader(PDF_PATH) spell = SpellChecker() - spell.word_frequency.load_words(CUSTOM_WORDS) - misspelled = spell.unknown(words) - assert not misspelled, f"Spelling errors found: {misspelled}" + # Build allowed word variations based on capitalization rules + allowed_words = set() + capitalization_errors = [] + + for custom_word in CUSTOM_WORDS: + if custom_word.islower(): + # All lowercase words allow both lowercase and first-letter capitalization + allowed_words.add(custom_word) + allowed_words.add(custom_word.capitalize()) + else: + # Words with specific capitalization must appear exactly as defined + allowed_words.add(custom_word) + + # Also add standard dictionary words + spell.word_frequency.load_words(allowed_words) + + # Extract all text from the PDF + full_text = "" + for page in pdf.pages: + full_text += page.extract_text() + + # Extract words preserving original case + original_words = re.findall(r"\b[a-zA-Z]+\b", full_text) + + # Check each word for spelling and capitalization + actual_misspelled = [] + for word in original_words: + # Skip very short words (likely abbreviations) + if len(word) < 3: + continue + # Skip single letters + if len(word) == 1: + continue + + # Check if word is in our allowed set or standard dictionary + if word in allowed_words or spell.known([word]): + continue + + # Check for capitalization errors in custom words + found_capitalization_error = False + for custom_word in CUSTOM_WORDS: + if word.lower() == custom_word.lower() and word != custom_word: + # If custom word is not all lowercase, it has specific capitalization requirements + if not custom_word.islower(): + capitalization_errors.append( + f"Found '{word}' but should be '{custom_word}'" + ) + found_capitalization_error = True + break + + if not found_capitalization_error: + # Additional filtering for likely valid words + if word.lower().startswith(".") or word.lower().endswith("."): + continue + if any(tech in word.lower() for tech in ["js", "sql", "xml", "json"]): + continue + + actual_misspelled.append(word) + + # Remove duplicates while preserving order + actual_misspelled = list(dict.fromkeys(actual_misspelled)) + + # Report both spelling and capitalization errors + all_errors = [] + if actual_misspelled: + all_errors.append(f"Misspelled words: {actual_misspelled}") + if capitalization_errors: + all_errors.append(f"Capitalization errors: {capitalization_errors}") + + assert len(all_errors) == 0, "; ".join(all_errors) + + +def test_pdf_structure(): + """Test basic PDF structure and readability.""" + pdf = PdfReader(PDF_PATH) + + # Test that PDF has metadata + metadata = pdf.metadata + assert metadata is not None, "PDF should have metadata" + + # Test that we can extract text from all pages + for page_num, page in enumerate(pdf.pages): + text = page.extract_text() + assert ( + len(text.strip()) > 0 + ), f"Page {page_num + 1} should contain readable text" + + +def test_pdf_not_corrupted(): + """Test that the PDF is not corrupted and can be properly read.""" + try: + pdf = PdfReader(PDF_PATH) + # Try to access all pages + for page in pdf.pages: + page.extract_text() + assert True + except Exception as e: + assert False, f"PDF appears to be corrupted: {e}" From e30c2c5e048e0264f2000f87557b61d533ad446f Mon Sep 17 00:00:00 2001 From: Dalton Luce Date: Wed, 17 Sep 2025 12:10:00 -0400 Subject: [PATCH 2/8] test: add font and metadata tests --- src/cv.tex | 7 +++++- test/test.py | 65 ++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/cv.tex b/src/cv.tex index acb03fe..4eaa901 100644 --- a/src/cv.tex +++ b/src/cv.tex @@ -44,11 +44,16 @@ \usepackage{titlesec} \usepackage[usenames,dvipsnames]{color} \usepackage{enumitem} -\usepackage[hidelinks]{hyperref} \usepackage{multicol} \usepackage{tabularx} \usepackage[T1]{fontenc} \input{glyphtounicode} +\usepackage[ + hidelinks, + % This adds metda data fields to the PDF + pdfauthor={Dalton Luce}, + pdftitle={Dalton Luce Resume}, +]{hyperref} %------------------------- % Page Layout diff --git a/test/test.py b/test/test.py index d9e804f..e070400 100644 --- a/test/test.py +++ b/test/test.py @@ -6,12 +6,15 @@ import fitz from PIL import Image import colorsys +from pdfminer.high_level import extract_pages +from pdfminer.layout import LTTextBox, LTTextLine, LTChar + # ---------- Configuration ---------- PDF_PATH = Path("dist/pdfs/dalton_luce_cv.pdf") MAX_PAGES = 1 # Max number of pages allowed in the PDF -MIN_FONT = 14 # TODO: Minimum font size allowed in points -MAX_FONT = 28 # TODO: Maximum font size allowed in points +MIN_FONT = 8 # Minimum font size allowed in points +MAX_FONT = 21 # aximum font size allowed in points ENFORCE_HTTPS = True # Require all links to use HTTPS (all links validated anyway) MAX_FILE_SIZE_KB = 500 # Max allowed PDF file size in kilobytes NO_IMAGES = True # PDF must contain no images if True @@ -124,21 +127,43 @@ def test_pdf_file_size(): ), f"CV file size is {file_size_kb:.1f}KB (should be ≤{MAX_FILE_SIZE_KB}KB)" -def test_pdf_font_sizes(): +def test_pdf_font_sizes(min_size=MIN_FONT, max_size=MAX_FONT): """Test that all fonts in the PDF are within the specified size range.""" - pdf = PdfReader(PDF_PATH) - font_sizes = set() - - for page in pdf.pages: - if "/Font" in page.get("/Resources", {}): - # Extract text with font information - text = page.extract_text() - # Note: pypdf doesn't easily extract font sizes, so we'll do a basic check - # This is a simplified implementation - in practice, you might need more sophisticated PDF parsing + errors = [] + + for page_layout in extract_pages(PDF_PATH): + page_num = page_layout.pageid + for element in page_layout: + if isinstance(element, (LTTextBox, LTTextLine)): + bad_chars = [] + bad_sizes = [] + for text_line in element: + for char in text_line: + if isinstance(char, LTChar): + font_size = char.size + if font_size < min_size or font_size > max_size: + bad_chars.append(char.get_text()) + bad_sizes.append(font_size) + + if bad_chars: + bad_text = "".join(bad_chars).strip() + # Find whether too small or too big, or both in the same snippet + too_small = any(size < min_size for size in bad_sizes) + too_big = any(size > max_size for size in bad_sizes) + + size_status = [] + if too_small: + size_status.append(f"smaller than minimum {min_size}") + if too_big: + size_status.append(f"larger than maximum {max_size}") + size_status_str = " and ".join(size_status) + + errors.append( + f"Page {page_num}: Text with font size {size_status_str}: '{bad_text}'" + ) - # For now, we'll assume the test passes if we can read the PDF - # A more complete implementation would require additional PDF parsing libraries - assert True, "Font size validation requires more sophisticated PDF parsing" + if errors: + raise AssertionError("\n".join(errors)) def test_pdf_links_https(): @@ -314,6 +339,16 @@ def test_pdf_structure(): metadata = pdf.metadata assert metadata is not None, "PDF should have metadata" + author = metadata.get("/Author") + assert ( + author is not None and author.strip() != "" + ), "PDF should have a valid Author metadata field" + + title = metadata.get("/Title") + assert ( + title is not None and title.strip() != "" + ), "PDF should have a valid Title metadata field" + # Test that we can extract text from all pages for page_num, page in enumerate(pdf.pages): text = page.extract_text() From 6a92ead79084543a56309dc134539dd8416ee03e Mon Sep 17 00:00:00 2001 From: Dalton Luce Date: Thu, 18 Sep 2025 23:44:02 -0400 Subject: [PATCH 3/8] refactor: use cvlint for CI/CD tests --- .github/workflows/deploy.yml | 22 +++ Makefile | 23 +-- pyproject.toml | 32 --- test/test.py | 369 ----------------------------------- 4 files changed, 24 insertions(+), 422 deletions(-) delete mode 100644 pyproject.toml delete mode 100644 test/test.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a6b86df..7f5c171 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,6 +3,8 @@ name: Deploy Resume on: push: branches: [main] + pull_request: + branches: [main] workflow_dispatch: env: @@ -30,7 +32,25 @@ jobs: - name: Build resume run: make all + # Test! + - name: Clone cvlint repo + run: git clone https://github.com/da-luce/cvlint.git + + - name: Install cvlint + run: | + cd cvlint + curl -sSL https://install.python-poetry.org | python3 - + export PATH="$HOME/.local/bin:$PATH" + poetry install + + # TODO: this is ugly + - name: Run tests + run: | + cd cvlint + poetry run cvlint check ../dist/pdfs/dalton_luce_cv.pdf + - name: Upload resume to S3 + if: github.event_name == 'push' uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -38,6 +58,7 @@ jobs: aws-region: ${{ secrets.AWS_REGION }} - name: Copy PDF + images to S3 + if: github.event_name == 'push' run: | aws s3 cp dist/pdfs/ s3://${{ secrets.AWS_S3_BUCKET }}/pdfs/ --recursive aws s3 cp dist/images/ s3://${{ secrets.AWS_S3_BUCKET }}/images/ --recursive @@ -45,6 +66,7 @@ jobs: # Purge GitHub's Camo CDN cache so updated resume/cover letter images show up immediately # Docs: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-anonymized-urls#removing-an-image-from-camos-cache - name: Clear GitHub Camo cache + if: github.event_name == 'push' run: | curl -X PURGE "${RESUME_IMAGE_URL}" curl -X PURGE "${COVER_LETTER_IMAGE_URL}" diff --git a/Makefile b/Makefile index 7167135..7005c60 100644 --- a/Makefile +++ b/Makefile @@ -56,26 +56,7 @@ images: $(DIST_DIR)/images clean: rm -rf $(BUILD_DIR) - -# Install Python dependencies in a venv -install: - @if [ ! -d .venv ]; then \ - echo "Creating virtual environment..."; \ - python3 -m venv .venv; \ - fi - @echo "Installing dependencies..." - @. .venv/bin/activate; \ - if [ -f pyproject.toml ]; then \ - pip install --upgrade pip; \ - pip install -e .; \ - else \ - echo "No dependency file found"; \ - fi - @echo "" - @echo "✅ Next steps:" - @echo " source .venv/bin/activate" - @echo " (or run commands with .venv/bin/python, e.g. .venv/bin/python -m pytest)" - # Run tests test: - python -m pytest test/test.py + # See https://github.com/da-luce/cvlint + cvlint check dist/pdfs/dalton_luce_cv.pdf diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index bd2d742..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,32 +0,0 @@ -[build-system] -requires = ["setuptools>=45", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "cv-tools" -version = "1.0.0" -description = "Tools for building and testing LaTeX CV" -authors = [{ name = "Dalton Luce", email = "daltonluce42@gmail.com" }] -readme = "README.md" -license = { text = "MIT" } -requires-python = ">=3.8" -dependencies = ["pypdf==6.0.0", "pyspellchecker==0.7.1", "pytest==8.4.0", "pymupdf>=1.24", "pillow>=11.3.0"] - -[project.optional-dependencies] -dev = ["black", "flake8", "mypy"] - -[tool.pytest.ini_options] -testpaths = ["test"] -python_files = ["test_*.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] - -[tool.black] -line-length = 88 -target-version = ['py38'] - -[tool.mypy] -python_version = "3.8" -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = true diff --git a/test/test.py b/test/test.py deleted file mode 100644 index e070400..0000000 --- a/test/test.py +++ /dev/null @@ -1,369 +0,0 @@ -from pypdf import PdfReader -from spellchecker import SpellChecker -from pathlib import Path -import re -import pytest -import fitz -from PIL import Image -import colorsys -from pdfminer.high_level import extract_pages -from pdfminer.layout import LTTextBox, LTTextLine, LTChar - - -# ---------- Configuration ---------- -PDF_PATH = Path("dist/pdfs/dalton_luce_cv.pdf") -MAX_PAGES = 1 # Max number of pages allowed in the PDF -MIN_FONT = 8 # Minimum font size allowed in points -MAX_FONT = 21 # aximum font size allowed in points -ENFORCE_HTTPS = True # Require all links to use HTTPS (all links validated anyway) -MAX_FILE_SIZE_KB = 500 # Max allowed PDF file size in kilobytes -NO_IMAGES = True # PDF must contain no images if True -MAX_COLORS = 1 # TODO: Max number of unique colors/hues allowed -BACKGROUND_WHITE = True # PDF background must be white if True -GRAYSCALE_COLORS = True # PDF colors must be grayscale only if True - -# Custom words to ignore in spellcheck - organized by category -# Words with specific capitalization must appear exactly as defined -# All lowercase words allow both lowercase and first-letter capitalization -TECH_WORDS = { - "API", - "APIs", - "AWS", - "CSS", - "HTML", - "JavaScript", - "TypeScript", - "Java", - "C++", - "python", - "nodejs", - "node.js", - "Docker", - "Kubernetes", - "terraform", - "Jenkins", - "GitLab", - "GitHub", - "lambda", - "lambdas", - "S3", - "DynamoDB", - "CloudWatch", - "devops", - "devsecops", - "ci/cd", - "serverless", - "frontend", - "backend", - "NumPy", - "OpenCV", - "OCaml", - "perl", - "Verilog", - "verliog", - "Groovy", - "Tailwind", - "Svelte", - "Homebrew", - "LocalStack", - "CloudFormation", - "workflows", - "integrations", - "validations", - "distributions", - "linting", - "debugged", - "prototyped", - "algorithims", - "kinematic", - "lidar", - "subteams", -} - -COMPANY_WORDS = { - "Raytheon", - "ClearCase", - "Jira", - "Grafana", - "ScriptRunner", - "astroterm", -} - -LOCATION_WORDS = {"Woburn", "Marlborough", "Ithaca", "Linux"} - -PERSONAL_WORDS = { - "daltonluce", - "daltonluce.com", - "LinkedIn", - "autobike", - "articlesand", - "applicationto", - "Aug", -} - -ACRONYM_WORDS = {"MSK", "ROS", "Nix", "NTP", "IEEE", "HKN", "CV", "step", "functions"} - -# Combine all custom words -CUSTOM_WORDS = ( - TECH_WORDS | COMPANY_WORDS | LOCATION_WORDS | PERSONAL_WORDS | ACRONYM_WORDS -) - - -# ---------- PDF Tests ---------- -def test_pdf_exists(): - assert PDF_PATH.is_file(), f"CV PDF not found at {PDF_PATH}" - - -def test_pdf_page_count(): - pdf = PdfReader(PDF_PATH) - assert len(pdf.pages) <= 1, f"CV is {len(pdf.pages)} pages (should be ≤1 page)" - - -def test_pdf_file_size(): - """Test that the PDF file size is within the specified limit.""" - file_size_kb = PDF_PATH.stat().st_size / 1024 - assert ( - file_size_kb <= MAX_FILE_SIZE_KB - ), f"CV file size is {file_size_kb:.1f}KB (should be ≤{MAX_FILE_SIZE_KB}KB)" - - -def test_pdf_font_sizes(min_size=MIN_FONT, max_size=MAX_FONT): - """Test that all fonts in the PDF are within the specified size range.""" - errors = [] - - for page_layout in extract_pages(PDF_PATH): - page_num = page_layout.pageid - for element in page_layout: - if isinstance(element, (LTTextBox, LTTextLine)): - bad_chars = [] - bad_sizes = [] - for text_line in element: - for char in text_line: - if isinstance(char, LTChar): - font_size = char.size - if font_size < min_size or font_size > max_size: - bad_chars.append(char.get_text()) - bad_sizes.append(font_size) - - if bad_chars: - bad_text = "".join(bad_chars).strip() - # Find whether too small or too big, or both in the same snippet - too_small = any(size < min_size for size in bad_sizes) - too_big = any(size > max_size for size in bad_sizes) - - size_status = [] - if too_small: - size_status.append(f"smaller than minimum {min_size}") - if too_big: - size_status.append(f"larger than maximum {max_size}") - size_status_str = " and ".join(size_status) - - errors.append( - f"Page {page_num}: Text with font size {size_status_str}: '{bad_text}'" - ) - - if errors: - raise AssertionError("\n".join(errors)) - - -def test_pdf_links_https(): - """Test that all links in the PDF use HTTPS when ENFORCE_HTTPS is True.""" - if not ENFORCE_HTTPS: - pytest.skip("HTTPS enforcement is disabled") - - pdf = PdfReader(PDF_PATH) - links = [] - - for page in pdf.pages: - if "/Annots" in page: - annotations = page["/Annots"] - for annotation in annotations: - annotation_obj = annotation.get_object() - if "/A" in annotation_obj and "/URI" in annotation_obj["/A"]: - uri = annotation_obj["/A"]["/URI"] - links.append(uri) - - for link in links: - assert link.startswith("https://"), f"Link '{link}' does not use HTTPS" - - -def test_pdf_no_images(): - """Test that the PDF contains no images when NO_IMAGES is True.""" - if not NO_IMAGES: - pytest.skip("Image checking is disabled") - - pdf = PdfReader(PDF_PATH) - - for page_num, page in enumerate(pdf.pages): - if "/XObject" in page.get("/Resources", {}): - xobjects = page["/Resources"]["/XObject"] - for obj_name in xobjects: - obj = xobjects[obj_name] - if obj.get("/Subtype") == "/Image": - assert ( - False - ), f"Found image '{obj_name}' on page {page_num + 1}, but NO_IMAGES is True" - - -def test_pdf_background_white(): - """Test that the PDF has a white background when BACKGROUND_WHITE is True.""" - if not BACKGROUND_WHITE: - pytest.skip("Background color checking is disabled") - - doc = fitz.open(PDF_PATH) - page = doc[0] - pix = page.get_pixmap(alpha=False) - - r, g, b = pix.pixel(0, 0) - doc.close() - - assert (r, g, b) == ( - 255, - 255, - 255, - ), f"Expected white background, got RGB({r}, {g}, {b})" - - -def rgb_to_hsv(rgb): - r, g, b = [x / 255.0 for x in rgb] - h, s, v = colorsys.rgb_to_hsv(r, g, b) - return h, s, v - - -def test_pdf_all_pixels_no_saturation(tolerance=0.01): - - if not GRAYSCALE_COLORS: - pytest.skip("Grayscale color checking is disabled") - """Test that every pixel in the PDF has saturation <= tolerance (i.e., grayscale).""" - doc = fitz.open(PDF_PATH) - - for page_num, page in enumerate(doc, start=1): - pix = page.get_pixmap(alpha=False) - image = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) - pixels = image.getdata() - - for i, pixel in enumerate(pixels): - _, s, _ = rgb_to_hsv(pixel) - if s > tolerance: - doc.close() - raise AssertionError( - f"Pixel with saturation {s:.3f} found on page {page_num} at pixel index {i}" - ) - - doc.close() - print("All pixels have zero (or near zero) saturation — PDF is grayscale.") - - -def test_pdf_spell_check(): - """Test that the PDF text passes spell checking with custom words and capitalization rules.""" - pdf = PdfReader(PDF_PATH) - spell = SpellChecker() - - # Build allowed word variations based on capitalization rules - allowed_words = set() - capitalization_errors = [] - - for custom_word in CUSTOM_WORDS: - if custom_word.islower(): - # All lowercase words allow both lowercase and first-letter capitalization - allowed_words.add(custom_word) - allowed_words.add(custom_word.capitalize()) - else: - # Words with specific capitalization must appear exactly as defined - allowed_words.add(custom_word) - - # Also add standard dictionary words - spell.word_frequency.load_words(allowed_words) - - # Extract all text from the PDF - full_text = "" - for page in pdf.pages: - full_text += page.extract_text() - - # Extract words preserving original case - original_words = re.findall(r"\b[a-zA-Z]+\b", full_text) - - # Check each word for spelling and capitalization - actual_misspelled = [] - for word in original_words: - # Skip very short words (likely abbreviations) - if len(word) < 3: - continue - # Skip single letters - if len(word) == 1: - continue - - # Check if word is in our allowed set or standard dictionary - if word in allowed_words or spell.known([word]): - continue - - # Check for capitalization errors in custom words - found_capitalization_error = False - for custom_word in CUSTOM_WORDS: - if word.lower() == custom_word.lower() and word != custom_word: - # If custom word is not all lowercase, it has specific capitalization requirements - if not custom_word.islower(): - capitalization_errors.append( - f"Found '{word}' but should be '{custom_word}'" - ) - found_capitalization_error = True - break - - if not found_capitalization_error: - # Additional filtering for likely valid words - if word.lower().startswith(".") or word.lower().endswith("."): - continue - if any(tech in word.lower() for tech in ["js", "sql", "xml", "json"]): - continue - - actual_misspelled.append(word) - - # Remove duplicates while preserving order - actual_misspelled = list(dict.fromkeys(actual_misspelled)) - - # Report both spelling and capitalization errors - all_errors = [] - if actual_misspelled: - all_errors.append(f"Misspelled words: {actual_misspelled}") - if capitalization_errors: - all_errors.append(f"Capitalization errors: {capitalization_errors}") - - assert len(all_errors) == 0, "; ".join(all_errors) - - -def test_pdf_structure(): - """Test basic PDF structure and readability.""" - pdf = PdfReader(PDF_PATH) - - # Test that PDF has metadata - metadata = pdf.metadata - assert metadata is not None, "PDF should have metadata" - - author = metadata.get("/Author") - assert ( - author is not None and author.strip() != "" - ), "PDF should have a valid Author metadata field" - - title = metadata.get("/Title") - assert ( - title is not None and title.strip() != "" - ), "PDF should have a valid Title metadata field" - - # Test that we can extract text from all pages - for page_num, page in enumerate(pdf.pages): - text = page.extract_text() - assert ( - len(text.strip()) > 0 - ), f"Page {page_num + 1} should contain readable text" - - -def test_pdf_not_corrupted(): - """Test that the PDF is not corrupted and can be properly read.""" - try: - pdf = PdfReader(PDF_PATH) - # Try to access all pages - for page in pdf.pages: - page.extract_text() - assert True - except Exception as e: - assert False, f"PDF appears to be corrupted: {e}" From c4e895fdac4242323517fe53be5eebaff7ded80d Mon Sep 17 00:00:00 2001 From: Dalton Luce Date: Fri, 19 Sep 2025 12:40:04 -0400 Subject: [PATCH 4/8] doc: update README --- Makefile | 8 ++++++-- README.md | 32 +++++++++++--------------------- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/Makefile b/Makefile index 7005c60..96b8389 100644 --- a/Makefile +++ b/Makefile @@ -58,5 +58,9 @@ clean: # Run tests test: - # See https://github.com/da-luce/cvlint - cvlint check dist/pdfs/dalton_luce_cv.pdf + @if ! command -v cvlint >/dev/null 2>&1; then \ + echo "Error: cvlint is not installed or not in PATH."; \ + echo "Please install cvlint by following instructions at https://github.com/da-luce/cvlint"; \ + exit 1; \ + fi + cvlint check dist/pdfs/dalton_luce_cv.pdf \ No newline at end of file diff --git a/README.md b/README.md index 93efa20..da3372c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # 📄 CV [![Deploy Resume](https://github.com/da-luce/cv/actions/workflows/deploy.yml/badge.svg)](https://github.com/da-luce/cv/actions/workflows/deploy.yml) +

@@ -11,9 +12,10 @@

+

-My [Curriculum Vitae](https://en.wikipedia.org/wiki/Curriculum_vitae) (CV) and cover letter template, written in $\LaTeX$ ([learn more](https://www.latex-project.org/)). All artifacts (PDFs and preview images) are automatically built using [GitHub Actions](https://github.com/features/actions) and uploaded to an [AWS S3](https://aws.amazon.com/s3/) bucket. A [Terraform](https://developer.hashicorp.com/terraform) configuration and [instructions](#deploying-artifacts-to-aws) are included below if you want to set it up yourself. +My [Curriculum Vitae](https://en.wikipedia.org/wiki/Curriculum_vitae) and cover letter template, written in $\LaTeX$. All artifacts (PDFs and preview images) are automatically built using GitHub Actions and uploaded to an AWS S3 bucket. A Terraform configuration and [instructions](#deploying-artifacts-to-aws) are included below if you want a similar settup for your own resume. Is it overengineered? _For most, probably yes._
Is it perfect for automation enthusiasts? _Absolutely._
@@ -59,9 +61,6 @@ Maybe I can convince you to become one too—_[here's why](#why-this-setup)._ To view other available targets, run `make help`. -> [!NOTE] -> The Makefile builds PDFs and images locally. PDFs and images are uploaded to an [S3](https://aws.amazon.com/s3/) bucket via a [GitHub Action](./.github/workflows/deploy.yml) for easy access. Storage cost is extremely low: for a 2 MB resume and cover letter, it would be just over a cent for three **years**. - ## Deploying Artifacts to AWS Follow these steps to set up your AWS account and configure GitHub Actions for automatic CV deployment. @@ -101,22 +100,13 @@ Follow these steps to set up your AWS account and configure GitHub Actions for a Once the secrets are added, the provided [workflow](./.github/workflows/deploy.yml) will automatically build your CV PDFs and preview images, then upload them to the configured S3 bucket whenever you push changes to the repository. -> **Note:** S3 storage is extremely cheap—effectively pennies for years of CV and cover letter artifacts. - ## Why This Setup? -| Component | Why? | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Git** | Tracks every change in fine detail—much more granular than cloud-hosted solutions like Google Drive. Supports collaboration, safe experimentation, and integrates seamlessly with automation workflows. | -| **LaTeX** | Produces professional, highly customizable PDFs. Standard in math and engineering fields, and easy to track with Git. | -| **AWS S3** | Cheap, reliable hosting. Stores the latest PDFs and images outside the repo (avoiding repo bloat) and provides a stable URL accessible by anyone on the internet. | -| **Terraform** | Makes AWS setup reproducible and version-controlled. Anyone (including future you) can recreate the environment easily. | - -## To be Implemented - -* [ ] Improve testing and add to `deploy.yml` action -* [x] Add Terraform template for AWS setup -* [x] Add details on AWS usage in README -* [x] GitHub action to only generate new PDFs/Images on `main` to reduce repo size -* [x] A more standard build process that works on any OS (✅ Makefile added) -* [x] Migrate from requirements.txt to pyproject.toml for Python dependencies +| Component | Why? | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [**Git**](https://git-scm.com/) | Tracks every change in fine detail—much more granular than cloud-hosted solutions like Google Drive. Supports collaboration, safe experimentation, and integrates seamlessly with automation workflows. | +| [**LaTeX**](https://www.latex-project.org/) | Produces professional, highly customizable PDFs. Standard in math and engineering fields, and easy to track with Git. | +| [**AWS S3**](https://aws.amazon.com/s3/) | Cheap (pennies for 2 MB of assets) and reliable hosting. Stores the latest PDFs and images outside the repo (avoiding repo bloat) and provides a stable URL accessible by anyone on the internet. | +| [**Terraform**](https://developer.hashicorp.com/terraform) | Makes AWS setup reproducible and version-controlled. Anyone (including future you) can recreate the environment easily. | +| [**GitHub Actions**](https://github.com/features/actions) | Automates the build, test, and deployment workflow on every push. Ensures your CV and cover letter are always up-to-date without manual intervention, seamlessly integrating with Git and AWS S3. | +| [**cvlint**](https://github.com/da-luce/cvlint) | Automatically checks your CV for formatting and content issues. | \ No newline at end of file From 7fb1b8e318791c9d3063bcd238736c3e93ead4bf Mon Sep 17 00:00:00 2001 From: Dalton Luce Date: Sat, 20 Sep 2025 19:31:07 -0400 Subject: [PATCH 5/8] build: add Dockerfile --- .github/workflows/deploy.yml | 62 +++++--------------------- .github/workflows/docker.yml | 26 +++++++++++ Dockerfile | 14 ++++++ Makefile | 71 +++++++++++++++++++----------- README.md | 2 +- src/{cv.tex => dalton_luce_cv.tex} | 0 6 files changed, 98 insertions(+), 77 deletions(-) create mode 100644 .github/workflows/docker.yml create mode 100644 Dockerfile rename src/{cv.tex => dalton_luce_cv.tex} (100%) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7f5c171..6eded7c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,17 +6,16 @@ on: pull_request: branches: [main] workflow_dispatch: - -env: - # These are GitHub Camo image proxy URLs. - # To obtain them: paste the raw S3 image link into a GitHub Markdown file (README, issue, etc.), - # let GitHub render it, then right-click → "Copy image address". - RESUME_IMAGE_URL: "https://camo.githubusercontent.com/19e0bf2e0d1d9b58f0bd2a4bf66b214f817d5ae7e72ff4e3684b8b15522be23f/68747470733a2f2f64616c746f6e2d63762d6172746966616374732e73332e75732d656173742d312e616d617a6f6e6177732e636f6d2f696d616765732f63762e706e67" - COVER_LETTER_IMAGE_URL: "https://camo.githubusercontent.com/31c147493e3d013bd48fcbeda87b802e2a8012a64faf6dc324f640ba7eec4f24/68747470733a2f2f64616c746f6e2d63762d6172746966616374732e73332e75732d656173742d312e616d617a6f6e6177732e636f6d2f696d616765732f636f7665725f6c65747465722e706e67" - jobs: build-and-deploy: runs-on: ubuntu-latest + container: + image: daluce/cv:latest + options: --entrypoint /bin/sh + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: ${{ secrets.AWS_REGION }} steps: - name: Checkout source @@ -24,49 +23,12 @@ jobs: with: fetch-depth: 0 - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y make texlive-latex-base texlive-latex-extra imagemagick - - name: Build resume run: make all - # Test! - - name: Clone cvlint repo - run: git clone https://github.com/da-luce/cvlint.git - - - name: Install cvlint - run: | - cd cvlint - curl -sSL https://install.python-poetry.org | python3 - - export PATH="$HOME/.local/bin:$PATH" - poetry install - - # TODO: this is ugly - - name: Run tests - run: | - cd cvlint - poetry run cvlint check ../dist/pdfs/dalton_luce_cv.pdf - - - name: Upload resume to S3 - if: github.event_name == 'push' - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ secrets.AWS_REGION }} - - - name: Copy PDF + images to S3 - if: github.event_name == 'push' - run: | - aws s3 cp dist/pdfs/ s3://${{ secrets.AWS_S3_BUCKET }}/pdfs/ --recursive - aws s3 cp dist/images/ s3://${{ secrets.AWS_S3_BUCKET }}/images/ --recursive - - # Purge GitHub's Camo CDN cache so updated resume/cover letter images show up immediately - # Docs: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-anonymized-urls#removing-an-image-from-camos-cache - - name: Clear GitHub Camo cache + - name: Test + run: make test + - name: Upload PDFs and images to S3 + # Only upload on pushes to main branch, not PRs if: github.event_name == 'push' - run: | - curl -X PURGE "${RESUME_IMAGE_URL}" - curl -X PURGE "${COVER_LETTER_IMAGE_URL}" + run: make release diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..9d0e9d2 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,26 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + paths: + - 'Dockerfile' + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + push: true + tags: daluce/cv:latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..847b2fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y \ + imagemagick \ + texlive-latex-base \ + texlive-latex-extra \ + git \ + awscli \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Install cvlint +RUN git clone https://github.com/da-luce/cvlint.git +WORKDIR /cvlint +RUN pip install . diff --git a/Makefile b/Makefile index 96b8389..21e9384 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean build private cover-letter images help install test +.PHONY: all clean build private cover-letter images help install test release # Default target all: build images @@ -7,54 +7,66 @@ all: build images BUILD_DIR := build DIST_DIR := dist SRC_DIR := src +PDF_DIR := $(DIST_DIR)/pdfs +IMG_DIR := $(DIST_DIR)/images +CV_NAME := dalton_luce_cv +COVER_LETTER_NAME := cover_letter +S3_BUCKET := dalton-cv-artifacts +AWS_REGION := us-east-1 + +# These are GitHub Camo image proxy URLs. +# To obtain them: paste the raw S3 image link into a GitHub Markdown file (README, issue, etc.), +# let GitHub render it, then right-click → "Copy image address". +CV_GITHUB_CACHE_URL:= https://camo.githubusercontent.com/19e0bf2e0d1d9b58f0bd2a4bf66b214f817d5ae7e72ff4e3684b8b15522be23f/68747470733a2f2f64616c746f6e2d63762d6172746966616374732e73332e75732d656173742d312e616d617a6f6e6177732e636f6d2f696d616765732f63762e706e67 +COVER_GITHUB_CACHE_URL:= https://camo.githubusercontent.com/31c147493e3d013bd48fcbeda87b802e2a8012a64faf6dc324f640ba7eec4f24/68747470733a2f2f64616c746f6e2d63762d6172746966616374732e73332e75732d656173742d312e616d617a6f6e6177732e636f6d2f696d616765732f636f7665725f6c65747465722e706e67 # Help target help: @echo "Available targets:" - @echo " all - Build CV and cover letter, generate images" - @echo " build - Build CV (public version)" - @echo " private - Build CV (private version)" - @echo " cover-letter - Build cover letter only" - @echo " images - Generate PNG images from PDFs" - @echo " clean - Remove build directory" - @echo " install - Install Python dependencies" - @echo " test - Run tests" + @echo " all - Build CV and cover letter, then generate images" + @echo " build - Build public version of the CV" + @echo " private - Build private version of the CV" + @echo " cover-letter - Build the cover letter PDF" + @echo " images - Generate PNG images from PDFs using ImageMagick" + @echo " clean - Remove all build and distribution artifacts" + @echo " install - Install Python dependencies (not defined here)" + @echo " test - Run cvlint on the generated CV PDF" + @echo " release - Upload PDFs and images to S3, purge GitHub Camo cache" # Create necessary directories -$(DIST_DIR)/pdfs $(DIST_DIR)/images: +$(PDF_DIR) $(IMG_DIR): mkdir -p $@ # Build public CV -build: $(DIST_DIR)/pdfs cover-letter - mkdir -p $(BUILD_DIR) - pdflatex -output-directory=$(BUILD_DIR) -jobname=dalton_luce_cv $(SRC_DIR)/cv.tex - cp $(BUILD_DIR)/dalton_luce_cv.pdf $(DIST_DIR)/pdfs/ +build: $(PDF_DIR) cover-letter + mkdir -p $(PDF_DIR) + pdflatex -output-directory=$(BUILD_DIR) -jobname=$(CV_NAME) $(SRC_DIR)/$(CV_NAME).tex + cp $(BUILD_DIR)/$(CV_NAME).pdf $(PDF_DIR) # Build private CV -private: $(DIST_DIR)/pdfs +private: $(PDF_DIR) mkdir -p $(BUILD_DIR) - pdflatex -output-directory=$(BUILD_DIR) -jobname=dalton_luce_cv '\def\private{} \input{$(SRC_DIR)/cv.tex}' - cp $(BUILD_DIR)/dalton_luce_cv.pdf $(DIST_DIR)/pdfs/ + pdflatex -output-directory=$(BUILD_DIR) -jobname=$(CV_NAME) "\def\\private{} \input{$(SRC_DIR)/$(CV_NAME).tex}" + cp $(BUILD_DIR)/$(CV_NAME).pdf $(PDF_DIR) # Build cover letter -cover-letter: $(DIST_DIR)/pdfs +cover-letter: $(PDF_DIR) mkdir -p $(BUILD_DIR) - pdflatex -output-directory=$(BUILD_DIR) -jobname=cover_letter $(SRC_DIR)/cover_letter.tex - cp $(BUILD_DIR)/cover_letter.pdf $(DIST_DIR)/pdfs/ + pdflatex -output-directory=$(BUILD_DIR) -jobname=cover_letter $(SRC_DIR)/$(COVER_LETTER_NAME).tex + cp $(BUILD_DIR)/$(COVER_LETTER_NAME).pdf $(PDF_DIR) # Generate images from PDFs -# FIXME: currently using deprecated "convert" since ubuntu GitHub runners only have access to IMv6 -images: $(DIST_DIR)/images +images: $(IMG_DIR) @if command -v convert >/dev/null 2>&1; then \ - convert -density 300 $(DIST_DIR)/pdfs/dalton_luce_cv.pdf -quality 100 -flatten -sharpen 0x1.0 $(DIST_DIR)/images/cv.png; \ - convert -density 300 $(DIST_DIR)/pdfs/cover_letter.pdf -quality 100 -flatten -sharpen 0x1.0 $(DIST_DIR)/images/cover_letter.png; \ + magick -density 300 $(DIST_DIR)/pdfs/dalton_luce_cv.pdf -quality 100 -flatten -sharpen 0x1.0 $(IMG_DIR)/$(CV_NAME).png; \ + magick -density 300 $(DIST_DIR)/pdfs/cover_letter.pdf -quality 100 -flatten -sharpen 0x1.0 $(IMG_DIR)/$(COVER_LETTER_NAME).png; \ else \ echo "Imageconvert not found. Please install Imageconvert to generate images."; \ fi # Clean build directory clean: - rm -rf $(BUILD_DIR) + rm -rf $(BUILD_DIR) $(DIST_DIR) # Run tests test: @@ -63,4 +75,11 @@ test: echo "Please install cvlint by following instructions at https://github.com/da-luce/cvlint"; \ exit 1; \ fi - cvlint check dist/pdfs/dalton_luce_cv.pdf \ No newline at end of file + cvlint check $(PDF_DIR)/$(CV_NAME).pdf + +release: + aws s3 cp $(PDF_DIR)/ s3://$(S3_BUCKET)/pdfs/ --recursive --region $(AWS_REGION) + aws s3 cp $(IMG_DIR)/ s3://$(S3_BUCKET)/images/ --recursive --region $(AWS_REGION) + # Purge GitHub Camo image cache, so updated images show up in README immediately + curl -X PURGE "$(CV_GITHUB_CACHE_URL)" + curl -X PURGE "$(COVER_GITHUB_CACHE_URL)" diff --git a/README.md b/README.md index da3372c..58550ac 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Maybe I can convince you to become one too—_[here's why](#why-this-setup)._ ### Curriculum Vitae   [![PDF](https://img.shields.io/badge/PDF-blue?style=flat)](https://dalton-cv-artifacts.s3.us-east-1.amazonaws.com/pdfs/dalton_luce_cv.pdf) -![CV Image](https://dalton-cv-artifacts.s3.us-east-1.amazonaws.com/images/cv.png) +![CV Image](https://dalton-cv-artifacts.s3.us-east-1.amazonaws.com/images/dalton_luce_cv.png) ### Cover Letter   [![PDF](https://img.shields.io/badge/PDF-blue?style=flat)](https://dalton-cv-artifacts.s3.us-east-1.amazonaws.com/pdfs/cover_letter.pdf) diff --git a/src/cv.tex b/src/dalton_luce_cv.tex similarity index 100% rename from src/cv.tex rename to src/dalton_luce_cv.tex From 87b2e1547620a2a3004b1d755162d86f015041b9 Mon Sep 17 00:00:00 2001 From: Dalton Luce Date: Sat, 20 Sep 2025 19:54:09 -0400 Subject: [PATCH 6/8] build: add make enter --- .github/workflows/deploy.yml | 1 - Dockerfile | 1 + Makefile | 3 +++ README.md | 30 ++++++++++++------------------ 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6eded7c..71e2a5f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,7 +11,6 @@ jobs: runs-on: ubuntu-latest container: image: daluce/cv:latest - options: --entrypoint /bin/sh env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/Dockerfile b/Dockerfile index 847b2fd..6529538 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ RUN apt-get update && apt-get install -y \ texlive-latex-extra \ git \ awscli \ + make \ && apt-get clean && rm -rf /var/lib/apt/lists/* # Install cvlint diff --git a/Makefile b/Makefile index 21e9384..888586b 100644 --- a/Makefile +++ b/Makefile @@ -83,3 +83,6 @@ release: # Purge GitHub Camo image cache, so updated images show up in README immediately curl -X PURGE "$(CV_GITHUB_CACHE_URL)" curl -X PURGE "$(COVER_GITHUB_CACHE_URL)" + +enter: + docker run --rm -it -v "$$(pwd)":/cv -w /cv daluce/cv:latest /bin/bash diff --git a/README.md b/README.md index 58550ac..a2493c9 100644 --- a/README.md +++ b/README.md @@ -33,30 +33,22 @@ Maybe I can convince you to become one too—_[here's why](#why-this-setup)._ ## Building -> [!NOTE] -> Requires `pdflatex` and `imagemagick` (for image generation). On macOS, you can install them with: -> -> ```shell -> brew install basictex imagemagick -> ``` -> -> Then reload your terminal to ensure the binaries are in your PATH. - -1. Install Python dependencies for testing: +1. Pull the pre-built Docker image ```shell - make install + docker pull daluce/cv:latest ``` +2. Start a shell inside the container + ```shell - source .venv/bin/activate - ```` + make enter + ``` -2. Build all outputs +3. Build and test all outputs ```shell - # Build everything (CV + cover letter + images) - make all + make all && make test ``` To view other available targets, run `make help`. @@ -98,7 +90,8 @@ Follow these steps to set up your AWS account and configure GitHub Actions for a 5. See it in action - Once the secrets are added, the provided [workflow](./.github/workflows/deploy.yml) will automatically build your CV PDFs and preview images, then upload them to the configured S3 bucket whenever you push changes to the repository. + Once the secrets are added, the provided [workflow](./.github/workflows/deploy.yml) will automatically build your CV PDFs and preview images, then upload them to the configured S3 bucket whenever you push changes to the repository. + You can also run `make release` to upload artifacts to S3. ## Why This Setup? @@ -109,4 +102,5 @@ Follow these steps to set up your AWS account and configure GitHub Actions for a | [**AWS S3**](https://aws.amazon.com/s3/) | Cheap (pennies for 2 MB of assets) and reliable hosting. Stores the latest PDFs and images outside the repo (avoiding repo bloat) and provides a stable URL accessible by anyone on the internet. | | [**Terraform**](https://developer.hashicorp.com/terraform) | Makes AWS setup reproducible and version-controlled. Anyone (including future you) can recreate the environment easily. | | [**GitHub Actions**](https://github.com/features/actions) | Automates the build, test, and deployment workflow on every push. Ensures your CV and cover letter are always up-to-date without manual intervention, seamlessly integrating with Git and AWS S3. | -| [**cvlint**](https://github.com/da-luce/cvlint) | Automatically checks your CV for formatting and content issues. | \ No newline at end of file +| [**cvlint**](https://github.com/da-luce/cvlint) | Automatically checks your CV for formatting and content issues. | +| [**Docker**](https://www.docker.com/) | Encapsulates all dependencies and tools needed to build your CV into one portable image, guaranteeing the build runs exactly the same anywhere. | From 14447829907e2ac86e35ce5d5f164a8389b03be6 Mon Sep 17 00:00:00 2001 From: Dalton Luce Date: Sat, 20 Sep 2025 20:08:23 -0400 Subject: [PATCH 7/8] fix: update image build to amd64 --- .github/workflows/docker.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9d0e9d2..dbb0f20 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -24,3 +24,4 @@ jobs: with: push: true tags: daluce/cv:latest + platforms: linux/amd64 From 00c2422a630fee2f6f338ee0de207088e99c85f8 Mon Sep 17 00:00:00 2001 From: Dalton Luce Date: Sat, 20 Sep 2025 20:16:26 -0400 Subject: [PATCH 8/8] minor tweaks --- Makefile | 2 +- README.md | 12 +++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index 888586b..6d7035c 100644 --- a/Makefile +++ b/Makefile @@ -85,4 +85,4 @@ release: curl -X PURGE "$(COVER_GITHUB_CACHE_URL)" enter: - docker run --rm -it -v "$$(pwd)":/cv -w /cv daluce/cv:latest /bin/bash + docker run --rm -it --pull always -v "$$(pwd)":/cv -w /cv daluce/cv:latest /bin/bash diff --git a/README.md b/README.md index a2493c9..1e1bd68 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ View PDF        - + View PNG

@@ -33,19 +33,13 @@ Maybe I can convince you to become one too—_[here's why](#why-this-setup)._ ## Building -1. Pull the pre-built Docker image - - ```shell - docker pull daluce/cv:latest - ``` - -2. Start a shell inside the container +1. Enter the development container (this requires Docker) ```shell make enter ``` -3. Build and test all outputs +2. Build and test all outputs ```shell make all && make test