From 87ea209d0ed498bdb9a048ba5e50d1e770cf49ad Mon Sep 17 00:00:00 2001 From: Amy Lin Date: Tue, 28 Jul 2026 15:36:44 -0500 Subject: [PATCH 1/2] ci: enforce quarto extensions render only what they serve Adds a lint-extension guard for Quarto content: it fails when a non-website extension renders more than one document, since with no pinned project.render the default served document can silently flip away from the entrypoint (#392) while every build-time check stays green. Addresses review feedback from #442: - Only skips the check when _quarto.yml is absent; an invalid _quarto.yml now fails quarto inspect loudly instead of passing. - Flags any stray rendered document (.qmd, .ipynb, etc.), not just Markdown. - Also retests complex extensions when the lint-extension action changes, matching the simple-extension _infra filter. --- .github/actions/lint-extension/action.yml | 66 +++++++++++++++++++++++ .github/workflows/extensions.yml | 2 + CONTRIBUTING.md | 22 ++++++++ 3 files changed, 90 insertions(+) diff --git a/.github/actions/lint-extension/action.yml b/.github/actions/lint-extension/action.yml index 4492d16c..44b5e36b 100644 --- a/.github/actions/lint-extension/action.yml +++ b/.github/actions/lint-extension/action.yml @@ -150,3 +150,69 @@ runs: exit 1 fi shell: bash + + # The render-set guard below only applies to Quarto content. + - name: Detect Quarto appmode + id: quarto-appmode + run: | + APPMODE=$(jq -r '.metadata.appmode // ""' ./extensions/${{ inputs.extension-name }}/manifest.json) + if [[ "$APPMODE" == quarto-* ]]; then + echo "is-quarto=true" >> "$GITHUB_OUTPUT" + else + echo "is-quarto=false" >> "$GITHUB_OUTPUT" + fi + shell: bash + + - name: Set up Quarto + if: steps.quarto-appmode.outputs.is-quarto == 'true' + uses: quarto-dev/quarto-actions/setup@v2 + + # Guards against the served-document bug (#392): Quarto renders every sibling + # document (e.g. CHANGELOG.md) as its own page, and with no pinned primary + # the default served document can silently flip to that page (e.g. + # CHANGELOG.html) instead of the entrypoint. Every build-time check stays + # green, so nothing else catches it. The standard is that a Quarto extension + # renders only what it serves: single-document content pins project.render to + # its entrypoint, while websites render their own pages. So this fails when a + # non-website extension renders more than one document. + - name: Check Quarto render set + if: steps.quarto-appmode.outputs.is-quarto == 'true' + run: | + set -euo pipefail + EXT_DIR=./extensions/${{ inputs.extension-name }} + + # Without a _quarto.yml the directory is not a project, so Connect renders + # only the entrypoint and no sibling document becomes a page. A + # _quarto.yml that exists but is invalid is a real error, so let + # `quarto inspect` fail loudly rather than treating it the same as "not + # a project". + if [ ! -f "$EXT_DIR/_quarto.yml" ]; then + echo "No _quarto.yml; only the entrypoint renders. OK." + exit 0 + fi + INSPECT=$(quarto inspect "$EXT_DIR") + + # Websites legitimately render many pages and serve index.html by convention. + PROJECT_TYPE=$(jq -r '.config.project.type // "default"' <<< "$INSPECT") + if [ "$PROJECT_TYPE" == "website" ]; then + echo "Website project; many pages are expected. OK." + exit 0 + fi + + # Rendered input basenames. Quarto ignores README.md, so it never + # shows up here even if one is present. + INPUTS=$(jq -r '.files.input[] | split("/") | last' <<< "$INSPECT") + COUNT=$(wc -l <<< "$INPUTS") + + # A lone document is unambiguously the served document. More than one + # rendered input, regardless of its extension (.md, .qmd, .ipynb, + # etc.), is a stray page that could become the served document. + if [ "$COUNT" -gt 1 ]; then + echo "Error: Quarto extension '${{ inputs.extension-name }}' renders multiple documents:" + echo "$INPUTS" + echo "Pin the entrypoint in _quarto.yml (project.render) so only it renders and becomes the served document." + exit 1 + fi + + echo "No stray pages render. OK." + shell: bash diff --git a/.github/workflows/extensions.yml b/.github/workflows/extensions.yml index 782414cc..90d8bedf 100644 --- a/.github/workflows/extensions.yml +++ b/.github/workflows/extensions.yml @@ -35,6 +35,7 @@ jobs: _infra: - integration/** - .github/actions/connect-integration-test/** + - .github/actions/lint-extension/** - .github/workflows/connect-integration-tests.yml - .github/workflows/extensions.yml quarto-stock-report-python: extensions/quarto-stock-report-python/** @@ -227,6 +228,7 @@ jobs: _infra: - integration/** - .github/actions/connect-integration-test/** + - .github/actions/lint-extension/** - .github/workflows/connect-integration-tests.yml - .github/workflows/extensions.yml publisher-command-center: extensions/publisher-command-center/** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a71e5c0..d3c550e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -442,3 +442,25 @@ to document changes made on each release. A recommended format is the [keep a changelog](https://keepachangelog.com/en/1.1.0/) format and ahering to the [Semantic Versioning](https://semver.org/) guidelines. + +### Quarto: render only what you serve + +Quarto renders every sibling Markdown file as its own page. It ignores +`README.md`, but not `CHANGELOG.md`. So a `CHANGELOG.md` in a Quarto extension +is rendered into a `CHANGELOG.html` page, and with no pinned primary the default +served document can silently flip to it instead of your entrypoint, with every +build-time check still passing. + +A Quarto extension should render only the document it serves. Pin the entrypoint +in `_quarto.yml` so nothing else renders: + +```yaml +project: + render: + - index.qmd # or your real entrypoint, e.g. script.py or script.R +``` + +Websites are the exception: they render many pages and serve `index.html` by +convention. Linting fails any non-website extension that renders more than one +document, whether a sibling Markdown page like a `CHANGELOG` or a stray +`.qmd`. From 563858d651e2599a91f9648694145de2dba71f41 Mon Sep 17 00:00:00 2001 From: Amy Lin Date: Tue, 28 Jul 2026 15:46:35 -0500 Subject: [PATCH 2/2] fix: don't hard-fail the render-set check on quarto inspect errors Inspecting script-r's bare .R entrypoint needs Rscript on PATH (knitr preprocesses it just to inspect it), which this lint job doesn't install. Treat a quarto inspect failure the same as "not a project" again instead of failing the lint, since this job isn't set up to provision every extension's runtime. --- .github/actions/lint-extension/action.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/actions/lint-extension/action.yml b/.github/actions/lint-extension/action.yml index 44b5e36b..b1840ff9 100644 --- a/.github/actions/lint-extension/action.yml +++ b/.github/actions/lint-extension/action.yml @@ -182,15 +182,15 @@ runs: EXT_DIR=./extensions/${{ inputs.extension-name }} # Without a _quarto.yml the directory is not a project, so Connect renders - # only the entrypoint and no sibling document becomes a page. A - # _quarto.yml that exists but is invalid is a real error, so let - # `quarto inspect` fail loudly rather than treating it the same as "not - # a project". - if [ ! -f "$EXT_DIR/_quarto.yml" ]; then - echo "No _quarto.yml; only the entrypoint renders. OK." + # only the entrypoint and no sibling document becomes a page. This also + # catches engines that need a runtime this job doesn't install (e.g. a + # bare .R script needs Rscript just to be inspected), so a quarto + # inspect failure is treated the same as "not a project" rather than + # failing the lint. + if ! INSPECT=$(quarto inspect "$EXT_DIR" 2>/dev/null); then + echo "Not a Quarto project, or inspection needs a runtime this job doesn't have; only the entrypoint renders. OK." exit 0 fi - INSPECT=$(quarto inspect "$EXT_DIR") # Websites legitimately render many pages and serve index.html by convention. PROJECT_TYPE=$(jq -r '.config.project.type // "default"' <<< "$INSPECT")