fix: pre-render all non-frozen QMDs to fix babelquarto freeze cache miss #418
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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('`)'); | |
| } | |
| 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 | |
| }); |