From 9746f577b4f34f42bb7ffdf7b799858251a1258e Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 11 Jun 2026 11:44:50 +0200 Subject: [PATCH 1/4] Add mergeRedirects helper to preserve manual _redirects lines --- .../actions/release-info/merge-redirects.js | 26 +++++++ .../actions/release-info/package.json | 2 +- .../release-info/test-merge-redirects.js | 70 +++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/actions/release-info/merge-redirects.js create mode 100644 .github/workflows/actions/release-info/test-merge-redirects.js diff --git a/.github/workflows/actions/release-info/merge-redirects.js b/.github/workflows/actions/release-info/merge-redirects.js new file mode 100644 index 0000000000..f3370b1321 --- /dev/null +++ b/.github/workflows/actions/release-info/merge-redirects.js @@ -0,0 +1,26 @@ +// Merge regenerated download redirect lines with the manually-maintained lines +// already present in a _redirects file. The action regenerates only the +// download namespaces (e.g. /download/latest/, /download/prerelease/); every +// other line (manual redirects, comments, blanks) is preserved. +function mergeRedirects(existingContent, downloadLines, managedPrefixes) { + const downloadBlock = downloadLines.join("\n"); + + const existingLines = existingContent ? existingContent.split("\n") : []; + const preserved = existingLines.filter((line) => { + const token = line.trimStart().split(/\s+/)[0]; + return !managedPrefixes.some((prefix) => token.startsWith(prefix)); + }); + + // Drop blank lines at the edges so re-runs stay stable. + while (preserved.length && preserved[0].trim() === "") preserved.shift(); + while (preserved.length && preserved[preserved.length - 1].trim() === "") { + preserved.pop(); + } + + if (preserved.length === 0) { + return downloadBlock; + } + return downloadBlock + "\n\n" + preserved.join("\n"); +} + +module.exports = { mergeRedirects }; diff --git a/.github/workflows/actions/release-info/package.json b/.github/workflows/actions/release-info/package.json index 5e0983d90c..e662e7a3a8 100644 --- a/.github/workflows/actions/release-info/package.json +++ b/.github/workflows/actions/release-info/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node test-merge-redirects.js" }, "keywords": [], "author": "", diff --git a/.github/workflows/actions/release-info/test-merge-redirects.js b/.github/workflows/actions/release-info/test-merge-redirects.js new file mode 100644 index 0000000000..b80986332e --- /dev/null +++ b/.github/workflows/actions/release-info/test-merge-redirects.js @@ -0,0 +1,70 @@ +const assert = require("assert"); +const { mergeRedirects } = require("./merge-redirects"); + +const downloadLines = [ + "/download/latest/quarto-win.msi https://example/v1/quarto-win.msi", + "/download/prerelease/quarto-win.msi https://example/v2/quarto-win.msi", +]; +const managedPrefixes = ["/download/latest/", "/download/prerelease/"]; + +// Fixture: stale download lines + a manual blog block (comment + rules). +const existing = [ + "/download/latest/quarto-win.msi https://example/OLD/quarto-win.msi", + "/download/prerelease/quarto-win.msi https://example/OLD2/quarto-win.msi", + "", + "# Quarto blog migrated", + "/docs/blog/posts/2022-02-13-feature-callouts/* https://opensource.posit.co/blog/2022-02-13_feature-callouts/ 301!", + "/docs/blog/ https://opensource.posit.co/blog/q/quarto/ 301!", +].join("\n"); + +const out = mergeRedirects(existing, downloadLines, managedPrefixes); + +// 1. Manual content preserved. +assert(out.includes("# Quarto blog migrated"), "comment preserved"); +assert( + out.includes("/docs/blog/posts/2022-02-13-feature-callouts/*"), + "per-post blog rule preserved" +); +assert( + out.includes("/docs/blog/ https://opensource.posit.co/blog/q/quarto/ 301!"), + "blog index rule preserved" +); + +// 2. Download lines regenerated; stale ones gone. +assert(out.includes("https://example/v1/quarto-win.msi"), "new latest url present"); +assert(out.includes("https://example/v2/quarto-win.msi"), "new prerelease url present"); +assert(!out.includes("OLD"), "stale download urls removed"); + +// 3. Download block on top, manual block below. +assert( + out.indexOf("/download/latest/") < out.indexOf("# Quarto blog"), + "download block precedes manual block" +); + +// 4. Idempotent: merging the output again yields identical content. +const out2 = mergeRedirects(out, downloadLines, managedPrefixes); +assert.strictEqual(out2, out, "mergeRedirects is idempotent"); + +// 5. Empty existing content -> download block only. +assert.strictEqual( + mergeRedirects("", downloadLines, managedPrefixes), + downloadLines.join("\n"), + "empty existing -> download block only" +); + +// 6. No trailing newline (matches committed _redirects format). +assert(!out.endsWith("\n"), "no trailing newline"); + +// 7. Prefix derivation used by index.js: template text before the first $$. +assert.strictEqual( + "/download/latest/$$prefix$$-$$suffix$$.$$extension$$".split("$$")[0], + "/download/latest/", + "latest prefix derived from template" +); +assert.strictEqual( + "/download/prerelease/$$prefix$$-$$suffix$$.$$extension$$".split("$$")[0], + "/download/prerelease/", + "prerelease prefix derived from template" +); + +console.log("All mergeRedirects tests passed"); From eefd4bb7c99d3c9a98e1a5a5ad5b78d9614b301d Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 11 Jun 2026 11:45:46 +0200 Subject: [PATCH 2/4] Preserve manual _redirects entries when regenerating download links --- .../workflows/actions/release-info/index.js | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/actions/release-info/index.js b/.github/workflows/actions/release-info/index.js index 1134006f1c..dcb39ee1fc 100644 --- a/.github/workflows/actions/release-info/index.js +++ b/.github/workflows/actions/release-info/index.js @@ -4,6 +4,7 @@ const fetch = require("node-fetch"); const hasha = require("hasha"); const fs = require("fs"); const path = require("path"); +const { mergeRedirects } = require("./merge-redirects"); async function run() { // Repo information @@ -171,14 +172,25 @@ async function run() { // Write the redirects if (redirectPath && redirectTemplate) { - const redirOutput = []; + const downloadLines = []; // Stable / latest release - redirOutput.push(...generateRedirects(redirects, redirectTemplate)); + downloadLines.push(...generateRedirects(redirects, redirectTemplate)); // Unstable / latest prerelease - redirOutput.push(...generateRedirects(prereleaseProcessed.redirects, preRedirectTemplate)); - - const redirOut = redirOutput.join("\n"); + downloadLines.push(...generateRedirects(prereleaseProcessed.redirects, preRedirectTemplate)); + + // The static path each template owns (text before the first $$ token), + // e.g. "/download/latest/" and "/download/prerelease/". Only lines under + // these namespaces are regenerated; all other lines are preserved. + const managedPrefixes = [redirectTemplate, preRedirectTemplate] + .filter(Boolean) + .map((t) => t.split("$$")[0]); + + const existing = fs.existsSync(redirectPath) + ? fs.readFileSync(redirectPath, "utf8") + : ""; + + const redirOut = mergeRedirects(existing, downloadLines, managedPrefixes); console.log(`Writing redirects file to ${redirectPath}`); console.log(`${redirOut}\n\n`); fs.writeFileSync(redirectPath, redirOut); From 48d67812da63290211c534372792b7001a55a633 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 11 Jun 2026 11:47:00 +0200 Subject: [PATCH 3/4] Restore blog redirects lost to the _redirects overwrite (#2036) --- _redirects | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/_redirects b/_redirects index 9004c48110..2c7edea1cb 100644 --- a/_redirects +++ b/_redirects @@ -23,4 +23,51 @@ /download/prerelease/quarto-macos.tar.gz https://github.com/quarto-dev/quarto-cli/releases/download/v1.10.11/quarto-1.10.11-macos.tar.gz /download/prerelease/quarto-win.msi https://github.com/quarto-dev/quarto-cli/releases/download/v1.10.11/quarto-1.10.11-win.msi /download/prerelease/quarto-win.zip https://github.com/quarto-dev/quarto-cli/releases/download/v1.10.11/quarto-1.10.11-win.zip -/download/prerelease/quarto-. https://github.com/quarto-dev/quarto-cli/releases/download/v1.10.11/quarto-1.10.11.tar.gz \ No newline at end of file +/download/prerelease/quarto-. https://github.com/quarto-dev/quarto-cli/releases/download/v1.10.11/quarto-1.10.11.tar.gz + +# Quarto blog migrated to the Posit Open Source site (2026-05). +# Per-post redirects first, then index and RSS feed. +/docs/blog/posts/2022-02-13-feature-callouts/* https://opensource.posit.co/blog/2022-02-13_feature-callouts/ 301! +/docs/blog/posts/2022-02-15-feature-tables/* https://opensource.posit.co/blog/2022-02-15_feature-tables/ 301! +/docs/blog/posts/2022-02-17-advanced-layout/* https://opensource.posit.co/blog/2022-02-17_advanced-layout/ 301! +/docs/blog/posts/2022-06-21-rstudio-conf-2022-quarto/* https://opensource.posit.co/blog/2022-06-21_rstudio-conf-2022-quarto/ 301! +/docs/blog/posts/2022-07-25-feature-extensions/* https://opensource.posit.co/blog/2022-07-25_feature-extensions/ 301! +/docs/blog/posts/2022-10-25-shinylive-extension/* https://opensource.posit.co/blog/2022-10-25_shinylive-extension/ 301! +/docs/blog/posts/2023-03-13-code-annotation/* https://opensource.posit.co/blog/2023-03-13_code-annotation/ 301! +/docs/blog/posts/2023-03-15-multi-format/* https://opensource.posit.co/blog/2023-03-15_multi-format/ 301! +/docs/blog/posts/2023-03-17-jupyter-cell-embedding/* https://opensource.posit.co/blog/2023-03-17_jupyter-cell-embedding/ 301! +/docs/blog/posts/2023-03-20-confluence/* https://opensource.posit.co/blog/2023-03-20_confluence/ 301! +/docs/blog/posts/2023-04-26-1.3-release/* https://opensource.posit.co/blog/2023-04-26_1.3-release/ 301! +/docs/blog/posts/2023-05-15-get-started/* https://opensource.posit.co/blog/2023-05-15_get-started/ 301! +/docs/blog/posts/2023-05-22-quarto-for-academics/* https://opensource.posit.co/blog/2023-05-22_quarto-for-academics/ 301! +/docs/blog/posts/2023-12-05-asa-traveling-courses/* https://opensource.posit.co/blog/2023-12-05_asa-traveling-courses/ 301! +/docs/blog/posts/2023-12-07-quarto-dashboards-demo/* https://opensource.posit.co/blog/2023-12-07_quarto-dashboards-demo/ 301! +/docs/blog/posts/2024-01-24-1.4-release/* https://opensource.posit.co/blog/2024-01-24_1.4-release/ 301! +/docs/blog/posts/2024-03-26-hugging-face/* https://opensource.posit.co/blog/2024-03-26_hugging-face/ 301! +/docs/blog/posts/2024-04-01-manuscripts-rmedicine/* https://opensource.posit.co/blog/2024-04-01_manuscripts-rmedicine/ 301! +/docs/blog/posts/2024-05-28-conf-workshops/* https://opensource.posit.co/blog/2024-05-28_conf-workshops/ 301! +/docs/blog/posts/2024-07-02-beautiful-tables-in-typst/* https://opensource.posit.co/blog/2024-07-02_beautiful-tables-in-typst/ 301! +/docs/blog/posts/2024-07-11-1.5-release/* https://opensource.posit.co/blog/2024-07-11_1.5-release/ 301! +/docs/blog/posts/2024-10-15-conf-workshops-materials/* https://opensource.posit.co/blog/2024-10-15_conf-workshops-materials/ 301! +/docs/blog/posts/2024-11-06-conf-talks/* https://opensource.posit.co/blog/2024-11-06_conf-talks/ 301! +/docs/blog/posts/2024-11-22-dashboards-workshop/* https://opensource.posit.co/blog/2024-11-22_dashboards-workshop/ 301! +/docs/blog/posts/2024-11-25-1.6-release/* https://opensource.posit.co/blog/2024-11-25_1.6-release/ 301! +/docs/blog/posts/2024-12-04-websites-workshop/* https://opensource.posit.co/blog/2024-12-04_websites-workshop/ 301! +/docs/blog/posts/2024-12-12-includes-meta/* https://opensource.posit.co/blog/2024-12-12_includes-meta/ 301! +/docs/blog/posts/2025-01-15-quarto-tip-brand-positron/* https://opensource.posit.co/blog/2025-01-15_quarto-tip-brand-positron/ 301! +/docs/blog/posts/2025-04-28-1.7-release/* https://opensource.posit.co/blog/2025-04-28_1.7-release/ 301! +/docs/blog/posts/2025-05-19-quarto-codespaces/* https://opensource.posit.co/blog/2025-05-19_quarto-codespaces/ 301! +/docs/blog/posts/2025-07-24-parameterized-reports-python/* https://opensource.posit.co/blog/2025-07-24_parameterized-reports-python/ 301! +/docs/blog/posts/2025-07-28-R-package-release-1.5/* https://opensource.posit.co/blog/2025-07-28_R-package-release-1.5/ 301! +/docs/blog/posts/2025-10-13-1.8-release/* https://opensource.posit.co/blog/2025-10-13_1.8-release/ 301! +/docs/blog/posts/2025-10-20-quarto-wizard-1-0-0/* https://opensource.posit.co/blog/2025-10-20_quarto-wizard-1-0-0/ 301! +/docs/blog/posts/2025-10-27-conf-workshops-materials/* https://opensource.posit.co/blog/2025-10-27_conf-workshops-materials/ 301! +/docs/blog/posts/2025-11-24-conf-talk-videos/* https://opensource.posit.co/blog/2025-11-24_conf-talk-videos/ 301! +/docs/blog/posts/2026-03-05-pdf-accessibility-and-standards/* https://opensource.posit.co/blog/2026-03-05_pdf-accessibility-and-standards/ 301! +/docs/blog/posts/2026-03-24-1.9-release/* https://opensource.posit.co/blog/2026-03-24_1.9-release/ 301! +/docs/blog/posts/2026-03-31-typst-books-and-more/* https://opensource.posit.co/blog/2026-03-31_typst-books-and-more/ 301! +/docs/blog/posts/2026-04-06-whats-next-quarto-2/* https://opensource.posit.co/blog/2026-04-06_whats-next-quarto-2/ 301! +/docs/blog/posts/2026-04-14-chrome-headless-shell/* https://opensource.posit.co/blog/2026-04-14_chrome-headless-shell/ 301! +/docs/blog/posts/2026-05-07-quarto-2-parsing/* https://opensource.posit.co/blog/2026-05-07_quarto-2-parsing/ 301! +/docs/blog/index.xml https://opensource.posit.co/blog/index.xml 301! +/docs/blog/ https://opensource.posit.co/blog/q/quarto/ 301! \ No newline at end of file From df5dd37620de92690480c9c95f90eaa257926299 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 11 Jun 2026 11:48:23 +0200 Subject: [PATCH 4/4] Overwrite-sync generated downloads to prerelease instead of cherry-pick --- .github/workflows/update-downloads.yml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/update-downloads.yml b/.github/workflows/update-downloads.yml index 46f06952ee..98e788e3be 100644 --- a/.github/workflows/update-downloads.yml +++ b/.github/workflows/update-downloads.yml @@ -10,7 +10,7 @@ jobs: outputs: changes_detected: ${{ steps.auto-commit.outputs.changes_detected }} commit: ${{ steps.auto-commit.outputs.commit_hash }} - commit_prerelease: ${{ steps.cherry-pick-prerelease.outputs.commit_hash }} + commit_prerelease: ${{ steps.sync-prerelease.outputs.commit_hash }} prerelease_version: ${{ steps.get-versions.outputs.prerelease_version }} steps: - name: Checkout Repo @@ -45,16 +45,24 @@ jobs: commit_user_email: runner@quarto.org commit_author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> - - name: Cherry-pick change to prerelease branch - id: cherry-pick-prerelease + - name: Sync generated downloads to prerelease branch + id: sync-prerelease if: ${{ steps.auto-commit.outputs.changes_detected == 'true' }} run: | git config --global user.name 'github-actions[bot]' git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com' + MAIN_SHA=${{ steps.auto-commit.outputs.commit_hash }} git checkout prerelease - git cherry-pick ${{ steps.auto-commit.outputs.commit_hash }} - git push origin prerelease - # set an output for github action with the resulted commit of cherry-pick command + # _redirects is authoritative on main (manual redirects reach prerelease + # via the backport workflow), so overwrite the generated files outright + # instead of cherry-picking — never conflicts. + git checkout "$MAIN_SHA" -- _redirects docs/download/_download.json docs/download/_prerelease.json + if git diff --cached --quiet; then + echo "No changes to sync to prerelease" + else + git commit -m "Sync generated download redirects from main" + git push origin prerelease + fi echo "commit_hash=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" # If a new commit has been made with updated downloads,