input commitCI-GATEoutput deployThe gatebetween draftand live.ci stops the bad build before the resume reachesanyoneASTRO · CI/CDop = arrest(commit) -> deploybuild
· 5 min read ·

Building a Filterable Resume Page with PDF Export and CI/CD for Astro

The resume page at /resume filters by years of experience (1, 2, 5, or 10), filters by company, and exports the same data as JSON, YAML, PDF, and DOCX. One file, src/data/resume.json, drives every view. Editing that file updates the page, the API endpoints, and the two pre-generated downloads.

I built it on December 22 alongside a CI/CD pipeline, because the next day a Dependabot PR tried to upgrade Tailwind CSS from v3 to v4 and I needed something between git push and production.

The Data Layer

LinkedIn exports the data you need. Settings & Privacy → Data Privacy → “Get a copy of your data” → “The works”. An email arrives in minutes for the small categories, up to 24 hours for everything. The ZIP contains a folder of CSV files: Profile.csv for headline and summary, Positions.csv for every job with company and dates and description, Education.csv, Skills.csv, Certifications.csv.

What it does not export: your profile photo, detailed job highlights (descriptions tend to be sparse), your LinkedIn URL itself, recommendations text.

linkedin-to-resume.mjs reads those CSVs, handles LinkedIn’s multiline quoted fields, maps to the JSON Resume schema, extracts bullet points from descriptions as highlights, and auto-categorizes skills into Cloud / Programming / Databases / etc.:

Terminal window
node scripts/linkedin-to-resume.mjs ~/Downloads/Basic_LinkedInDataExport_12-22-2025/
const positions = readCSVFile(exportDir, 'Positions.csv');
const work = positions.map(pos => ({
name: pos['Company Name'],
position: pos['Title'],
startDate: formatDate(pos['Started On']), // "Jan 2020" → "2020-01"
endDate: formatDate(pos['Finished On']),
summary: pos['Description'],
highlights: extractBulletPoints(pos['Description'])
}));

The output is a JSON file ready for editing. Thirty minutes of enrichment turned the sparse LinkedIn data into specific achievements with metrics.

A recruiter looking for the last 2 years clicks a button and sees exactly that.

The Filtering

Client-side JavaScript filters without page reloads:

const filterExperience = (years) => {
const cutoffDate = new Date();
cutoffDate.setFullYear(cutoffDate.getFullYear() - years);
document.querySelectorAll('.experience-item').forEach(item => {
const endDate = item.dataset.endDate;
const show = !endDate || new Date(endDate) >= cutoffDate;
item.style.display = show ? 'block' : 'none';
});
};

A recruiter looking for the last 2 years clicks a button and sees exactly that.

Thirty minutes of enrichment turned the sparse LinkedIn data into specific achievements with metrics.

The PDF

My first attempt used html2pdf.js for client-side generation. Content Security Policy blocked the CDN script.

The fix was to generate at build time. A Node script launches Puppeteer, points it at the locally-served /resume/, injects a compact stylesheet (11px body, hidden nav), and writes the result to dist/resume.pdf:

async function generatePDF() {
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.goto('http://localhost:3456/resume/', {
waitUntil: 'networkidle0'
});
await page.evaluate(() => {
const style = document.createElement('style');
style.textContent = `
body { font-size: 11px !important; }
h1 { font-size: 24px !important; }
`;
document.head.appendChild(style);
document.querySelector('nav').style.display = 'none';
});
await page.pdf({
path: 'dist/resume.pdf',
format: 'Letter',
printBackground: true,
margin: { top: '0.4in', right: '0.4in', bottom: '0.4in', left: '0.4in' }
});
await browser.close();
}

DOCX is built programmatically from the same JSON via the docx library: a Document with sections of Paragraph and TextRun nodes serialized through Packer.toBuffer(), then written to dist/resume.docx.

Puppeteer and docx are installed locally but kept out of package.json. Cloudflare’s build step does not need either, and including them produced a peer-dependency conflict with Tailwind that broke the deploy. npm install puppeteer docx runs once on my laptop; npm run resume:generate runs the two scripts:

Terminal window
# Local only, not in package.json
npm install puppeteer docx
# Generate resume files
npm run resume:generate

The Pipeline

ci.yml (and the sibling ci-tech.yml for the tech subsite) runs two jobs on every push and PR. build-and-test checks out, sets up Node 22 with npm cache, runs npm ci, builds the site, and validates that every image reference resolves. security-audit runs npm audit --audit-level=high and fails the build on vulnerabilities. Lighthouse runs on PRs only.

jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npm run validate:images
security-audit:
runs-on: ubuntu-latest
steps:
- run: npm ci
- run: npm audit --audit-level=high

Dependabot auto-merges patch and minor updates when CI passes. Major updates get a warning comment and stay open for human review:

- name: Auto-merge patch updates
if: steps.metadata.outputs.update-type == 'version-update:semver-patch'
run: gh pr merge --auto --squash "$PR_URL"
- name: Comment on major updates
if: steps.metadata.outputs.update-type == 'version-update:semver-major'
run: |
gh pr comment "$PR_URL" --body "Major version update, requires manual review"

The Tailwind v4 PR

The day after I wired the pipeline, Dependabot opened a PR upgrading Tailwind from v3 to v4. v4 is a complete rewrite; the build would have failed and the auto-merge would have refused it on the major-version rule, but I closed the PR with an explanation and added a permanent ignore so future v4 PRs do not reappear:

.github/dependabot.yml
ignore:
- dependency-name: "tailwindcss"
update-types: ["version-update:semver-major"]

The same rule shape now blocks any major-version surprise on the packages I am not ready to migrate.


src/data/resume.json is the single source of truth, linkedin-to-resume.mjs is the import path, Puppeteer plus docx are the build-time exporters, and ci.yml plus the Dependabot config are the seatbelt. The JSON Resume schema means the data is portable if I ever swap tools; the API endpoints (/api/resume.json, /api/resume.yaml) mean other tools can read it without scraping the page.

The same rule shape now blocks any major-version surprise on the packages I am not ready to migrate.