Skip to content

fix: pre-render all non-frozen QMDs to fix babelquarto freeze cache miss #418

fix: pre-render all non-frozen QMDs to fix babelquarto freeze cache miss

fix: pre-render all non-frozen QMDs to fix babelquarto freeze cache miss #418

Workflow file for this run

on:
pull_request_target:
branches: [main, master]
name: Render the new Quarto files
# https://github.com/r-lib/actions/tree/v2/examples#render-rmarkdown
# https://github.com/quarto-dev/quarto-actions
env:
isExtPR: ${{ github.event.pull_request.head.repo.fork == true }}
RUST_BACKTRACE: 1
jobs:
# ── Job 1: Format check ────────────────────────────────────────────────────
# Security note: Uses only the GitHub API to read changed file contents;
# NO checkout of untrusted PR code occurs in this job.
format-check:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Detect changed QMD files and run format check
id: format-check
uses: actions/github-script@v7
with:
script: |
// ── Get list of changed files in the PR ───────────────────────
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
const qmdFiles = files.filter(f =>
(f.filename.endsWith('.qmd') || f.filename.endsWith('.Qmd')) &&
!f.filename.endsWith('.zh.qmd') &&
f.status !== 'removed' &&
f.filename.includes('/') // exclude root-level QMD files (site navigation pages)
);
if (qmdFiles.length === 0) {
core.setOutput('summary', 'No new QMD tutorial files to check.');
core.setOutput('has_errors', 'false');
return;
}
// ── Validation helpers ────────────────────────────────────────
const REQUIRED_YAML_FIELDS = ['title', 'author'];
const REQUIRED_SECTIONS = ['## Example', '## Setup', '## Data Preparation', '## Visualization'];
const errors = {};
const warnings = {};
for (const f of qmdFiles) {
let content;
try {
const { data: blob } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: f.filename,
ref: context.payload.pull_request.head.sha,
});
content = Buffer.from(blob.content, 'base64').toString('utf8');
} catch (e) {
console.log(`Could not fetch ${f.filename}: ${e.message}`);
continue;
}
const fp = f.filename;
const fileErrors = [];
const fileWarnings = [];
// 1. YAML frontmatter
const yamlMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!yamlMatch) {
fileErrors.push('Missing YAML frontmatter (expected `---` block at top)');
} else {
const yaml = yamlMatch[1];
for (const field of REQUIRED_YAML_FIELDS) {
if (!new RegExp(`^${field}\\s*:`, 'm').test(yaml)) {
fileErrors.push(`Missing required YAML field: \`${field}\``);
}
}
}
// 2. Required sections
for (const section of REQUIRED_SECTIONS) {
if (!content.includes(section)) {
fileWarnings.push(`Missing recommended section: \`${section}\``);
}
}
// 3. Figure code blocks should have labels
const codeBlockRe = /```\{r([^}]*)\}([\s\S]*?)```/g;
let m;
while ((m = codeBlockRe.exec(content)) !== null) {
const opts = m[1], body = m[2];
// Accept labels in both the old {r label, ...} header form
// and the Quarto YAML-options "#| label:" form inside the body
const hasLabel = opts.includes('label') || body.includes('#| label:');
if ((body.includes('fig-cap') || body.includes('#| fig-cap:')) && !hasLabel) {
const lineNo = content.slice(0, m.index).split('\n').length;
fileWarnings.push(`Code block near line ${lineNo} has \`fig-cap\` but no \`label\``);
}
}
// 4. Demo image
if (!content.includes('![')) {
fileWarnings.push('No demo image found (recommended: `![](../images/...)`)');
}
if (fileErrors.length) errors[fp] = fileErrors;
if (fileWarnings.length) warnings[fp] = fileWarnings;
}
// ── Build summary ──────────────────────────────────────────────
const hasErrors = Object.keys(errors).length > 0;
const lines = [];
if (hasErrors) {
lines.push('### ❌ Format Errors (must fix)');
for (const [fp, errs] of Object.entries(errors)) {
lines.push(`\n**\`${fp}\`**`);
errs.forEach(e => lines.push(`- ${e}`));
}
}
if (Object.keys(warnings).length > 0) {
lines.push('\n### ⚠️ Format Warnings (recommended)');
for (const [fp, warns] of Object.entries(warnings)) {
lines.push(`\n**\`${fp}\`**`);
warns.forEach(w => lines.push(`- ${w}`));
}
}
if (!hasErrors && Object.keys(warnings).length === 0) {
lines.push(`### ✅ All ${qmdFiles.length} checked QMD file(s) pass format validation!`);
}
const summary = lines.join('\n');
core.setOutput('summary', summary);
core.setOutput('has_errors', String(hasErrors));
if (hasErrors) core.setFailed('QMD format errors found');
- name: Post format check comment on PR
if: always()
uses: actions/github-script@v7
env:
FORMAT_SUMMARY: ${{ steps.format-check.outputs.summary }}
HAS_ERRORS: ${{ steps.format-check.outputs.has_errors }}
with:
script: |
const summary = process.env.FORMAT_SUMMARY || '(no output)';
const hasErrors = process.env.HAS_ERRORS === 'true';
const icon = hasErrors ? '❌' : '✅';
const body = `## ${icon} QMD Format Check\n\n${summary}\n\n` +
`> Automated check by the [PR Review workflow]` +
`(${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).\n` +
`> See [contribution guidance](../blob/main/Template/visualization_guidance_EN.qmd) for the expected tutorial format.`;
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number, body
});
# ── Job 2: Render preview ──────────────────────────────────────────────────
build-deploy:
runs-on: ubuntu-latest
needs: format-check
if: "!failure()"
concurrency:
group: pr-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Set up Quarto
uses: quarto-dev/quarto-actions/setup@v2
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tinytex: true
- uses: r-lib/actions/setup-r@v2
with:
use-public-rspm: true
- name: Cache R packages
uses: actions/cache@v4
with:
path: |
~/.local/share/renv
~/.cache/R/renv
/usr/local/lib/R/site-library
~/R
key: r-packages-${{ runner.os }}-${{ hashFiles('DESCRIPTION') }}
restore-keys: |
r-packages-${{ runner.os }}-
- uses: r-lib/actions/setup-r-dependencies@v2
- name: Scan .qmd for dependencies and update
run: Rscript update-dependencies.R
- name: Get the new quarto files
id: new-qmd-files
uses: tj-actions/changed-files@v46.0.5 # v46
with:
files: |
**.Qmd
**.qmd
- name: Render the new quarto files
if: steps.new-qmd-files.outputs.any_changed == 'true'
env:
QMD_FILES: ${{ steps.new-qmd-files.outputs.all_changed_files }}
run: |
# every qmd file should be self-contained
mkdir -p _QMD_RENDER_REVIEW
mkdir -p _QMD_RENDER_WORKING_DIR
for file in ${QMD_FILES}; do
myfile=$(basename "$file")
myname="${myfile%.*}" # remove file extension
mkdir -p "_QMD_RENDER_REVIEW/$myname"
cp "$file" "_QMD_RENDER_WORKING_DIR/$myfile"
echo "Rendering $file"
quarto render "_QMD_RENDER_WORKING_DIR/$myfile" --execute-dir .
mv _QMD_RENDER_WORKING_DIR/* "_QMD_RENDER_REVIEW/$myname/"
done
- name: Upload Quarto artifacts
if: steps.new-qmd-files.outputs.any_changed == 'true'
uses: actions/upload-artifact@v4
id: artifact-upload
with:
name: quarto-rendered-files
path: _QMD_RENDER_REVIEW
if-no-files-found: "ignore"
- name: Comment PR with artifact link
if: steps.new-qmd-files.outputs.any_changed == 'true' && github.event_name == 'pull_request_target'
uses: actions/github-script@v7
env:
CHANGED_FILES: ${{ steps.new-qmd-files.outputs.all_changed_files }}
with:
script: |
const runId = context.runId;
const repo = context.repo;
const artifactUrl = `https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId}`;
const changedFiles = process.env.CHANGED_FILES;
const comment = `## 📊 Quarto Render Preview
The new or modified Quarto files have been rendered and are available as artifacts.
**🔗 [View and Download Rendered Files](${artifactUrl})**
Click the link above, scroll to the "Artifacts" section at the bottom of the page, and download \`quarto-rendered-files\` to preview the rendered HTML files.
**Modified files:**
\`\`\`
${changedFiles}
\`\`\`
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: repo.owner,
repo: repo.repo,
body: comment
});