From e67c644c3f1566450c6993eabcd20845665f0ee6 Mon Sep 17 00:00:00 2001 From: edavidaja Date: Fri, 19 Sep 2025 12:21:25 -0400 Subject: [PATCH] Add RPM support and Cloudsmith publishing for Linux packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces comprehensive RPM package support alongside existing DEB packages, migrates to nfpm for package building, and adds automated Cloudsmith publishing. Package Building Changes: - Migrated from dpkg-deb to nfpm for both DEB and RPM package creation - Added makeInstallerRpm() function in installer.ts using shared nfpm configuration - Created RPM-specific post-install and post-remove scripts in package/scripts/linux/rpm/ - Added js-yaml import for generating nfpm configuration files - Implemented architecture mapping (DEB: amd64/arm64, RPM: x86_64/aarch64) GitHub Actions Workflow Improvements: - Consolidated 4 separate Linux installer jobs into single matrix job (make-installer-linux) - Matrix: arch=[x86_64, aarch64] × format=[deb, rpm] - Reduced workflow from ~160 to ~60 lines for Linux installers - Added nfpm installation step (downloads binary from GitHub releases, version 2.43.1) - Updated all artifact references to use new naming: Linux-{format}-{arch}-Installer Cloudsmith Publishing: - Added cloudsmith-push job for automated package repository publishing - Publishes to both "open" and "pro" Posit repositories - Uses matrix strategy: 2 archs × 2 formats × 2 repos = 8 push operations - Configured with any-distro/any-version for broad compatibility Build System Updates: - Added make-installer-rpm command to bld.ts - Updated all job dependencies (publish-release, cleanup-when-failure, docker-push) - Updated checksum generation and release file paths for new artifact names This enables users to install Quarto via standard package managers (apt, yum, dnf, zypper) from Posit's Cloudsmith repositories after configuring the appropriate repository source. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .github/workflows/create-release.yml | 151 +++++++++++++------ package/scripts/linux/rpm/postinst | 51 +++++++ package/scripts/linux/rpm/postrm | 19 +++ package/src/bld.ts | 6 +- package/src/linux/installer.ts | 217 ++++++++++++++++----------- 5 files changed, 307 insertions(+), 137 deletions(-) create mode 100644 package/scripts/linux/rpm/postinst create mode 100644 package/scripts/linux/rpm/postrm diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index db427db6a95..aa1f7896338 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -15,6 +15,9 @@ on: type: boolean default: true +env: + NFPM_VERSION: "2.43.1" + concurrency: # make publishing release concurrent (but others trigger not) group: building-releases-${{ inputs.publish-release && 'prerelease' || github.run_id }} @@ -240,9 +243,13 @@ jobs: name: RHEL Zip path: ./package/quarto-${{needs.configure.outputs.version}}-linux-rhel7-amd64.tar.gz - make-installer-arm64-deb: + make-installer-linux: runs-on: ubuntu-latest needs: [configure] + strategy: + matrix: + arch: [x86_64, aarch64] + format: [deb, rpm] steps: - uses: actions/checkout@v4 with: @@ -256,57 +263,47 @@ jobs: run: | ./configure.sh - - name: Prepare Distribution - run: | - pushd package/src/ - ./quarto-bld prepare-dist --set-version ${{needs.configure.outputs.version}} --arch aarch64 --log-level info - popd - - - name: Make Installer - run: | - pushd package/src/ - ./quarto-bld make-installer-deb --set-version ${{needs.configure.outputs.version}} --arch aarch64 --log-level info - popd - - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: Deb Arm64 Installer - path: ./package/out/quarto-${{needs.configure.outputs.version}}-linux-arm64.deb - - make-installer-deb: - runs-on: ubuntu-latest - needs: [configure] - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.configure.outputs.version_commit }} - - - name: Prevent Re-run - if: ${{ inputs.publish-release }} - uses: ./.github/workflows/actions/prevent-rerun - - - name: Configure + - name: Install nfpm run: | - ./configure.sh + wget -q https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz + tar -xzf nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz + sudo mv nfpm /usr/local/bin/ + nfpm --version - name: Prepare Distribution run: | pushd package/src/ - ./quarto-bld prepare-dist --set-version ${{needs.configure.outputs.version}} --log-level info + ./quarto-bld prepare-dist --set-version ${{needs.configure.outputs.version}} ${{ matrix.arch == 'aarch64' && '--arch aarch64' || '' }} --log-level info popd - name: Make Installer run: | pushd package/src/ - ./quarto-bld make-installer-deb --set-version ${{needs.configure.outputs.version}} --log-level info + ./quarto-bld make-installer-${{ matrix.format }} --set-version ${{needs.configure.outputs.version}} ${{ matrix.arch == 'aarch64' && '--arch aarch64' || '' }} --log-level info popd + - name: Set package architecture name + id: pkg_arch + run: | + if [ "${{ matrix.format }}" == "deb" ]; then + if [ "${{ matrix.arch }}" == "x86_64" ]; then + echo "arch_name=amd64" >> $GITHUB_OUTPUT + else + echo "arch_name=arm64" >> $GITHUB_OUTPUT + fi + else + if [ "${{ matrix.arch }}" == "x86_64" ]; then + echo "arch_name=x86_64" >> $GITHUB_OUTPUT + else + echo "arch_name=aarch64" >> $GITHUB_OUTPUT + fi + fi + - name: Upload Artifact uses: actions/upload-artifact@v4 with: - name: Deb Installer - path: ./package/out/quarto-${{needs.configure.outputs.version}}-linux-amd64.deb + name: Linux-${{ matrix.format }}-${{ matrix.arch }}-Installer + path: ./package/out/quarto-${{needs.configure.outputs.version}}-linux-${{ steps.pkg_arch.outputs.arch_name }}.${{ matrix.format }} test-tarball-linux: runs-on: ubuntu-latest @@ -593,8 +590,7 @@ jobs: runs-on: ubuntu-latest needs: [ configure, - make-installer-deb, - make-installer-arm64-deb, + make-installer-linux, make-installer-win, make-installer-mac, # optional in release to not be blocked by RHEL build depending on conda-forge deno dependency @@ -660,13 +656,21 @@ jobs: sha256sum quarto-${{needs.configure.outputs.version}}-linux-arm64.tar.gz >> ../quarto-${{needs.configure.outputs.version}}-checksums.txt popd - pushd Deb\ Installer + pushd Linux-deb-x86_64-Installer sha256sum quarto-${{needs.configure.outputs.version}}-linux-amd64.deb >> ../quarto-${{needs.configure.outputs.version}}-checksums.txt popd - pushd Deb\ Arm64\ Installer + pushd Linux-deb-aarch64-Installer sha256sum quarto-${{needs.configure.outputs.version}}-linux-arm64.deb >> ../quarto-${{needs.configure.outputs.version}}-checksums.txt - popd + popd + + pushd Linux-rpm-x86_64-Installer + sha256sum quarto-${{needs.configure.outputs.version}}-linux-x86_64.rpm >> ../quarto-${{needs.configure.outputs.version}}-checksums.txt + popd + + pushd Linux-rpm-aarch64-Installer + sha256sum quarto-${{needs.configure.outputs.version}}-linux-aarch64.rpm >> ../quarto-${{needs.configure.outputs.version}}-checksums.txt + popd pushd Source sha256sum quarto-${{needs.configure.outputs.version}}.tar.gz >> ../quarto-${{needs.configure.outputs.version}}-checksums.txt @@ -690,8 +694,10 @@ jobs: ./Deb Zip/quarto-${{needs.configure.outputs.version}}-linux-amd64.tar.gz ./Deb Arm64 Zip/quarto-${{needs.configure.outputs.version}}-linux-arm64.tar.gz ./RHEL Zip/quarto-${{needs.configure.outputs.version}}-linux-rhel7-amd64.tar.gz - ./Deb Installer/quarto-${{needs.configure.outputs.version}}-linux-amd64.deb - ./Deb Arm64 Installer/quarto-${{needs.configure.outputs.version}}-linux-arm64.deb + ./Linux-deb-x86_64-Installer/quarto-${{needs.configure.outputs.version}}-linux-amd64.deb + ./Linux-deb-aarch64-Installer/quarto-${{needs.configure.outputs.version}}-linux-arm64.deb + ./Linux-rpm-x86_64-Installer/quarto-${{needs.configure.outputs.version}}-linux-x86_64.rpm + ./Linux-rpm-aarch64-Installer/quarto-${{needs.configure.outputs.version}}-linux-aarch64.rpm ./Windows Installer/quarto-${{needs.configure.outputs.version}}-win.msi ./Windows Zip/quarto-${{needs.configure.outputs.version}}-win.zip ./Mac Installer/quarto-${{needs.configure.outputs.version}}-macos.pkg @@ -703,8 +709,7 @@ jobs: if: ${{ (failure() || cancelled()) && inputs.publish-release }} needs: [ configure, - make-installer-deb, - make-installer-arm64-deb, + make-installer-linux, make-installer-win, make-installer-mac, # optional in release to not be blocked by RHEL build depending on conda-forge deno dependency @@ -761,10 +766,62 @@ jobs: - uses: ./.github/actions/docker with: - source: ./Deb Installer/quarto-${{needs.configure.outputs.version}}-linux-amd64.deb + source: ./Linux-deb-x86_64-Installer/quarto-${{needs.configure.outputs.version}}-linux-amd64.deb version: ${{needs.configure.outputs.version}} token: ${{ secrets.GITHUB_TOKEN }} username: ${{ github.actor }} org: ${{ github.repository_owner }} name: quarto daily: ${{ inputs.pre-release }} + + cloudsmith-push: + if: ${{ inputs.publish-release }} + runs-on: ubuntu-latest + needs: [configure, publish-release] + strategy: + matrix: + arch: [x86_64, aarch64] + format: [deb, rpm] + repo: [open, pro] + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + .github + + - name: Prevent Re-run + if: ${{ inputs.publish-release }} + uses: ./.github/workflows/actions/prevent-rerun + + - name: Download Artifacts + uses: actions/download-artifact@v4 + + - name: Set package file name + id: pkg_file + run: | + if [ "${{ matrix.format }}" == "deb" ]; then + if [ "${{ matrix.arch }}" == "x86_64" ]; then + echo "arch_name=amd64" >> $GITHUB_OUTPUT + else + echo "arch_name=arm64" >> $GITHUB_OUTPUT + fi + else + if [ "${{ matrix.arch }}" == "x86_64" ]; then + echo "arch_name=x86_64" >> $GITHUB_OUTPUT + else + echo "arch_name=aarch64" >> $GITHUB_OUTPUT + fi + fi + + - name: Push ${{ matrix.format }} ${{ matrix.arch }} to Cloudsmith ${{ matrix.repo }} + uses: cloudsmith-io/action@master + with: + api-key: ${{ secrets.CLOUDSMITH_API_KEY }} + command: "push" + format: "${{ matrix.format }}" + owner: "posit" + repo: "${{ matrix.repo }}" + distro: "any-distro" + release: "any-version" + republish: "true" + file: "./Linux-${{ matrix.format }}-${{ matrix.arch }}-Installer/quarto-${{needs.configure.outputs.version}}-linux-${{ steps.pkg_file.outputs.arch_name }}.${{ matrix.format }}" diff --git a/package/scripts/linux/rpm/postinst b/package/scripts/linux/rpm/postinst new file mode 100644 index 00000000000..d5dce53bf47 --- /dev/null +++ b/package/scripts/linux/rpm/postinst @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -e + +# detect whether running as root (per machine installation) +# if per machine (run without sudo): + +if [[ $EUID -eq 0 ]]; then + if [ -d "/usr/local/bin" ] + then + ln -fs /opt/quarto/bin/quarto /usr/local/bin/quarto + else + echo "Quarto symlink not created, please be sure that you add Quarto to your path." + fi + + if [ -d "/usr/local/man/man1" ] + then + ln -fs /opt/quarto/share/man/quarto.man /usr/local/man/man1/quarto.1 + elif [ -d "/usr/local/man" ] + then + ln -fs /opt/quarto/share/man/quarto.man /usr/local/man/quarto.1 + fi + +else + if [ -d "~/bin/quarto" ] + then + ln -fs /opt/quarto/bin/quarto ~/bin/quarto + else + echo "Quarto symlink not created, please be sure that you add Quarto to your path." + fi + + if [ -d "~/man/man1" ] + then + ln -fs /opt/quarto/share/man/quarto.man ~/man/man1/quarto.1 + elif [ -d "~/man" ] + then + ln -fs /opt/quarto/share/man/quarto.man ~/man/quarto.1 + fi + +fi + +# Figure architecture +NIXARCH=$(uname -m) +if [[ $NIXARCH == "aarch64" ]]; then + ARCH_DIR=aarch64 +else + ARCH_DIR=x86_64 +fi + +ln -fs /opt/quarto/bin/tools/${ARCH_DIR}/pandoc /opt/quarto/bin/tools/pandoc + +exit 0 \ No newline at end of file diff --git a/package/scripts/linux/rpm/postrm b/package/scripts/linux/rpm/postrm new file mode 100644 index 00000000000..cac6088cdd1 --- /dev/null +++ b/package/scripts/linux/rpm/postrm @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -e + +if [[ "$EUID" -eq 0 ]] +then +rm -f /usr/local/bin/quarto +else +rm -f ~/bin/quarto +fi + +# Remove pandoc symlink created by postinst +# (before 1.4 this was a regular file that shouldn't be removed here) +pandoc=/opt/quarto/bin/tools/pandoc +if [ -h "$pandoc" ] +then + rm -f "$pandoc" +fi + +exit 0 \ No newline at end of file diff --git a/package/src/bld.ts b/package/src/bld.ts index 7bdbc86861f..ff69ab48c73 100644 --- a/package/src/bld.ts +++ b/package/src/bld.ts @@ -10,7 +10,7 @@ import { mainRunner } from "../../src/core/main.ts"; import { prepareDist } from "./common/prepare-dist.ts"; import { updateHtmlDependencies } from "./common/update-html-dependencies.ts"; -import { makeInstallerDeb } from "./linux/installer.ts"; +import { makeInstallerDeb, makeInstallerRpm } from "./linux/installer.ts"; import { makeInstallerMac } from "./macos/installer.ts"; import { compileQuartoLatexmkCommand, @@ -96,6 +96,10 @@ function getCommands() { packageCommand(makeInstallerDeb, "make-installer-deb") .description("Builds Linux deb installer"), ); + commands.push( + packageCommand(makeInstallerRpm, "make-installer-rpm") + .description("Builds Linux rpm installer"), + ); commands.push( packageCommand(makeInstallerWindows, "make-installer-win") .description("Builds Windows installer"), diff --git a/package/src/linux/installer.ts b/package/src/linux/installer.ts index fa0e6e0d90c..89a5a0ed5cd 100644 --- a/package/src/linux/installer.ts +++ b/package/src/linux/installer.ts @@ -5,25 +5,111 @@ * */ import { join } from "../../../src/deno_ral/path.ts"; -import { copySync, emptyDirSync, ensureDirSync, walk } from "../../../src/deno_ral/fs.ts"; +import { copySync, emptyDirSync, ensureDirSync, existsSync, walk } from "../../../src/deno_ral/fs.ts"; import { info } from "../../../src/deno_ral/log.ts"; +import * as yaml from "../../../src/core/lib/external/js-yaml.js"; import { Configuration } from "../common/config.ts"; import { runCmd } from "../util/cmd.ts"; -export async function makeInstallerDeb( +// Map architecture names between Quarto and package formats +function mapArchitecture(arch: string, format: 'deb' | 'rpm'): string { + if (format === 'deb') { + return arch === 'x86_64' ? 'amd64' : 'arm64'; + } else { // rpm + return arch === 'x86_64' ? 'x86_64' : 'aarch64'; + } +} + +// Create nfpm configuration for DEB or RPM packages +async function createNfpmConfig( configuration: Configuration, + format: 'deb' | 'rpm', + workingDir: string, ) { - info("Building deb package..."); - - // detect packaging machine architecture - // See complete list dpkg-architecture -L. - // arm64 - // amd64 - const architecture = configuration.arch === "x86_64" ? "amd64" : "arm64"; - const packageName = - `quarto-${configuration.version}-linux-${architecture}.deb`; - info("Building package " + packageName); + const arch = mapArchitecture(configuration.arch, format); + const workingBinPath = join( + workingDir, + "opt", + configuration.productName.toLowerCase(), + "bin", + ); + const workingSharePath = join( + workingDir, + "opt", + configuration.productName.toLowerCase(), + "share", + ); + + // Calculate installed size + const fileSizes = []; + for await (const entry of walk(configuration.directoryInfo.pkgWorking.root)) { + if (entry.isFile) { + fileSizes.push((await Deno.stat(entry.path)).size); + } + } + const size = fileSizes.reduce((accum, target) => accum + target, 0); + + const config: any = { + name: configuration.productName.toLowerCase(), + version: configuration.version, + arch: arch, + maintainer: "Posit, PBC ", + description: "Quarto is an academic, scientific, and technical publishing system built on Pandoc.", + homepage: "https://github.com/quarto-dev/quarto-cli", + license: "MIT", + + contents: [ + { + src: workingBinPath, + dst: "/opt/quarto/bin", + type: "tree", + }, + { + src: workingSharePath, + dst: "/opt/quarto/share", + type: "tree", + }, + ], + + scripts: { + postinstall: join(configuration.directoryInfo.pkg, "scripts", "linux", format, "postinst"), + postremove: join(configuration.directoryInfo.pkg, "scripts", "linux", format, "postrm"), + }, + + overrides: {}, + }; + + // Format-specific configuration + if (format === 'deb') { + config.overrides.deb = { + recommends: ["unzip"], + }; + // Add Debian-specific metadata + config.section = "user/text"; + config.priority = "optional"; + config.installed_size = Math.round(size / 1024); + } else if (format === 'rpm') { + config.overrides.rpm = { + compression: "gzip", + summary: "Academic, scientific, and technical publishing system", + group: "Applications/Publishing", + }; + } + + return config; +} + +// Build package using nfpm +async function buildPackageWithNfpm( + configuration: Configuration, + format: 'deb' | 'rpm', +) { + const packageExt = format === 'deb' ? 'deb' : 'rpm'; + const arch = mapArchitecture(configuration.arch, format); + const packageName = `quarto-${configuration.version}-linux-${arch}.${packageExt}`; + + info(`Building ${format.toUpperCase()} package: ${packageName}`); // Prepare working directory const workingDir = join(configuration.directoryInfo.out, "working"); @@ -31,7 +117,7 @@ export async function makeInstallerDeb( ensureDirSync(workingDir); emptyDirSync(workingDir); - // Copy bin into the proper path in working dir + // Copy bin and share directories const workingBinPath = join( workingDir, "opt", @@ -54,85 +140,38 @@ export async function makeInstallerDeb( overwrite: true, }); - const val = (name: string, value: string): string => { - return `${name}: ${value}\n`; - }; + // Create nfpm configuration + const nfpmConfig = await createNfpmConfig(configuration, format, workingDir); + const configPath = join(configuration.directoryInfo.out, "nfpm.yaml"); - // Calculate the install size - const fileSizes = []; - for await (const entry of walk(configuration.directoryInfo.pkgWorking.root)) { - if (entry.isFile) { - fileSizes.push((await Deno.stat(entry.path)).size); - } - } - const size = fileSizes.reduce((accum, target) => { - return accum + target; - }); - const url = "https://github.com/quarto-dev/quarto-cli"; - const recommends = ["unzip"]; - - // Make the control file - info("Creating control file"); - let control = ""; - control = control + val("Package", configuration.productName); - if (recommends.length) { - control = control + val("Recommends", recommends.join(",")); - } - control = control + val("Version", configuration.version); - control = control + val("Architecture", architecture); - control = control + val("Installed-Size", `${Math.round(size / 1024)}`); - control = control + val("Section", "user/text"); - control = control + val("Priority", "optional"); - control = control + val("Maintainer", "Posit, PBC "); - control = control + val("Homepage", url); - control = control + - val( - "Description", - "Quarto is an academic, scientific, and technical publishing system built on Pandoc.", - ); - info(control); - - // Place - const debianDir = join(workingDir, "DEBIAN"); - ensureDirSync(debianDir); - - // Write the control file to the DEBIAN directory - Deno.writeTextFileSync(join(debianDir, "control"), control); - - // Generate and write a copyright file - info("Creating copyright file"); - const copyrightLines = []; - copyrightLines.push( - "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/", - ); - copyrightLines.push("Upstream-Name: Quarto"); - copyrightLines.push(`Source: ${url}`); - copyrightLines.push(""); - copyrightLines.push("Files: *"); - copyrightLines.push("Copyright: Posit, PBC."); - copyrightLines.push("License: MIT"); - const copyrightText = copyrightLines.join("\n"); - Deno.writeTextFileSync(join(debianDir, "copyright"), copyrightText); - - // copy the install scripts - info("Copying install scripts..."); - copySync( - join(configuration.directoryInfo.pkg, "scripts", "linux", "deb"), - debianDir, - { overwrite: true }, - ); + info("Creating nfpm configuration file"); + Deno.writeTextFileSync(configPath, yaml.dump(nfpmConfig)); - await runCmd("dpkg-deb", [ - "-Z", - "gzip", - "-z", - "9", - "--root-owner-group", - "--build", - workingDir, - join(configuration.directoryInfo.out, packageName), + // Build package using nfpm (assumes nfpm is installed in PATH) + const outputPath = join(configuration.directoryInfo.out, packageName); + await runCmd("nfpm", [ + "package", + "--config", configPath, + "--target", outputPath, + "--packager", format, ]); - // Remove the working directory + info(`Package created: ${outputPath}`); + + // Clean up + Deno.removeSync(configPath); + // Optionally remove working directory // Deno.removeSync(workingDir, { recursive: true }); } + +export async function makeInstallerDeb( + configuration: Configuration, +) { + await buildPackageWithNfpm(configuration, 'deb'); +} + +export async function makeInstallerRpm( + configuration: Configuration, +) { + await buildPackageWithNfpm(configuration, 'rpm'); +} \ No newline at end of file