Skip to content

Package DEB/RPM

Package DEB/RPM #2

Workflow file for this run

name: Package DEB/RPM
on:
workflow_call:
inputs:
tag:
description: "Release tag to package"
required: true
type: string
build_run_id:
description: "Build and Release workflow run ID that produced the artifacts"
required: true
type: string
head_sha:
description: "Commit SHA built by the Build and Release workflow"
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: "Release tag to package (for example v0.1.24 or v0.1.24-rc.1)"
required: true
type: string
build_run_id:
description: "Successful Build and Release workflow run ID to package"
required: true
type: string
head_sha:
description: "Commit SHA for manually dispatched builds (optional for tag-push runs)"
required: false
type: string
permissions:
contents: write
actions: read
concurrency:
group: package-${{ inputs.tag || github.run_id }}
cancel-in-progress: true
jobs:
resolve:
name: Resolve Build
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
actions: read
outputs:
version: ${{ steps.resolve.outputs.version }}
package_version: ${{ steps.resolve.outputs.package_version }}
build_run_id: ${{ steps.resolve.outputs.build_run_id }}
tag: ${{ steps.resolve.outputs.tag }}
head_sha: ${{ steps.resolve.outputs.head_sha }}
steps:
- name: Resolve and validate build run
id: resolve
env:
GH_TOKEN: ${{ github.token }}
INPUT_TAG: ${{ inputs.tag || github.event.inputs.tag }}
INPUT_RUN_ID: ${{ inputs.build_run_id || github.event.inputs.build_run_id }}
INPUT_HEAD_SHA: ${{ inputs.head_sha || github.event.inputs.head_sha }}
REPOSITORY: ${{ github.repository }}
CURRENT_RUN_ID: ${{ github.run_id }}
shell: bash
run: |
set -euo pipefail
TAG="${INPUT_TAG}"
BUILD_RUN_ID="${INPUT_RUN_ID}"
EXPECTED_SHA="${INPUT_HEAD_SHA,,}"
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
echo "Input tag is not a release tag: ${TAG}"
exit 1
fi
if [[ ! "${BUILD_RUN_ID}" =~ ^[0-9]+$ ]]; then
echo "build_run_id must be a numeric workflow run ID"
exit 1
fi
if [[ -n "${EXPECTED_SHA}" && ! "${EXPECTED_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "head_sha must be a 40-character commit SHA"
exit 1
fi
RUN_PATH=$(gh api "repos/${REPOSITORY}/actions/runs/${BUILD_RUN_ID}" --jq '.path')
RUN_CONCLUSION=$(gh api "repos/${REPOSITORY}/actions/runs/${BUILD_RUN_ID}" --jq '.conclusion // ""')
RUN_EVENT=$(gh api "repos/${REPOSITORY}/actions/runs/${BUILD_RUN_ID}" --jq '.event')
RUN_HEAD_SHA=$(gh api "repos/${REPOSITORY}/actions/runs/${BUILD_RUN_ID}" --jq '.head_sha')
RUN_HEAD_SHA="${RUN_HEAD_SHA,,}"
if [[ "${RUN_PATH}" != ".github/workflows/release.yml" ]]; then
echo "Workflow run ${BUILD_RUN_ID} is not a Build and Release run"
exit 1
fi
if [[ "${RUN_EVENT}" != "push" && "${RUN_EVENT}" != "workflow_dispatch" ]]; then
echo "Workflow run ${BUILD_RUN_ID} was triggered by an unsupported event: ${RUN_EVENT}"
exit 1
fi
if [[ "${RUN_CONCLUSION}" != "success" ]]; then
if [[ -z "${RUN_CONCLUSION}" && "${BUILD_RUN_ID}" == "${CURRENT_RUN_ID}" ]]; then
echo "Validating artifacts from the current release workflow run"
elif [[ "${RUN_CONCLUSION}" == "failure" ]]; then
FAILED_REQUIRED_JOBS=$(gh api \
"repos/${REPOSITORY}/actions/runs/${BUILD_RUN_ID}/jobs?filter=latest&per_page=100" \
--paginate \
--jq '.jobs[] | select((.name | startswith("Package DEB/RPM /") | not) and .conclusion != "success" and .conclusion != "skipped") | .name')
if [[ -n "${FAILED_REQUIRED_JOBS}" ]]; then
echo "Workflow run ${BUILD_RUN_ID} has failed required jobs: ${FAILED_REQUIRED_JOBS}"
exit 1
fi
echo "Retrying packaging after an isolated DEB/RPM workflow failure"
else
echo "Workflow run ${BUILD_RUN_ID} did not complete successfully: ${RUN_CONCLUSION}"
exit 1
fi
fi
if [[ ! "${RUN_HEAD_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Workflow run ${BUILD_RUN_ID} returned an invalid head SHA"
exit 1
fi
TAG_SHA=$(gh api "repos/${REPOSITORY}/commits/${TAG}" --jq '.sha')
TAG_SHA="${TAG_SHA,,}"
if [[ ! "${TAG_SHA}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Tag ${TAG} did not resolve to a commit SHA"
exit 1
fi
if [[ "${RUN_EVENT}" == "push" && "${RUN_HEAD_SHA}" != "${TAG_SHA}" ]]; then
echo "Push workflow run ${BUILD_RUN_ID} (${RUN_HEAD_SHA}) does not build tag ${TAG} (${TAG_SHA})"
exit 1
fi
if [[ -n "${EXPECTED_SHA}" ]]; then
if [[ "${EXPECTED_SHA}" != "${TAG_SHA}" ]]; then
echo "The supplied head SHA does not match tag ${TAG}"
exit 1
fi
SOURCE_SHA="${EXPECTED_SHA}"
elif [[ "${RUN_EVENT}" == "push" ]]; then
SOURCE_SHA="${RUN_HEAD_SHA}"
else
echo "head_sha is required when the build run was not triggered by a tag push"
exit 1
fi
PACKAGE_VERSION="${TAG#v}"
PACKAGE_VERSION="${PACKAGE_VERSION/-/~}"
{
echo "version=${TAG}"
echo "package_version=${PACKAGE_VERSION}"
echo "build_run_id=${BUILD_RUN_ID}"
echo "tag=${TAG}"
echo "head_sha=${SOURCE_SHA}"
} >> "$GITHUB_OUTPUT"
echo "Resolved tag: ${TAG}"
echo "Resolved package version: ${PACKAGE_VERSION}"
echo "Validated release workflow run: ${BUILD_RUN_ID}"
echo "Validated source SHA: ${SOURCE_SHA}"
package:
name: Package (${{ matrix.arch }})
needs: resolve
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
actions: read
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
rpm_arch: x86_64
artifact_name: rustfs-cli-linux-amd64
- arch: arm64
rpm_arch: aarch64
artifact_name: rustfs-cli-linux-arm64
steps:
- name: Checkout exact build source
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.resolve.outputs.head_sha }}
persist-credentials: false
- name: Download binary artifact from release build
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: ${{ matrix.artifact_name }}
path: ./binary-artifact
run-id: ${{ needs.resolve.outputs.build_run_id }}
github-token: ${{ github.token }}
merge-multiple: true
- name: Download completions artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: completions
path: ./completions-artifact
run-id: ${{ needs.resolve.outputs.build_run_id }}
github-token: ${{ github.token }}
- name: Extract release assets
shell: bash
run: |
set -euo pipefail
TAR_FILE=$(find ./binary-artifact -type f -name '*.tar.gz' -print -quit)
if [[ -z "${TAR_FILE}" ]]; then
echo "No Linux binary archive found"
find ./binary-artifact -maxdepth 2 -type f -print || true
exit 1
fi
mkdir -p ./binary-extract
tar -xzf "${TAR_FILE}" -C ./binary-extract
if [[ ! -f ./binary-extract/rc ]]; then
echo "The release archive does not contain rc"
exit 1
fi
BINARY_INFO=$(file ./binary-extract/rc)
echo "${BINARY_INFO}"
if ! grep -qiE 'static|statically linked' <<< "${BINARY_INFO}"; then
echo "Package inputs must use a statically linked Linux binary"
exit 1
fi
mkdir -p ./pkg-root/usr/bin
install -m 755 ./binary-extract/rc ./pkg-root/usr/bin/rc
mkdir -p ./pkg-root/usr/share/doc/rustfs-cli
cp LICENSE-MIT LICENSE-APACHE README.md ./pkg-root/usr/share/doc/rustfs-cli/
mkdir -p ./pkg-root/usr/share/bash-completion/completions
mkdir -p ./pkg-root/usr/share/zsh/site-functions
mkdir -p ./pkg-root/usr/share/fish/vendor_completions.d
if [[ ! -f ./completions-artifact/completions.tar.gz ]]; then
echo "No completions archive found"
find ./completions-artifact -maxdepth 2 -type f -print || true
exit 1
fi
tar -xzf ./completions-artifact/completions.tar.gz -C ./completions-artifact
cp ./completions-artifact/completions/rc.bash ./pkg-root/usr/share/bash-completion/completions/rc
cp ./completions-artifact/completions/_rc ./pkg-root/usr/share/zsh/site-functions/_rc
cp ./completions-artifact/completions/rc.fish ./pkg-root/usr/share/fish/vendor_completions.d/rc.fish
- name: Install packaging tools
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y fakeroot ruby ruby-dev build-essential rpm
sudo gem install --no-document fpm -v 1.15.1
- name: Build DEB package
id: deb
shell: bash
run: |
set -euo pipefail
VERSION="${{ needs.resolve.outputs.package_version }}"
PKG_DIR="rustfs-cli_${VERSION}_${{ matrix.arch }}"
mkdir -p "${PKG_DIR}/DEBIAN"
cp -R ./pkg-root/. "${PKG_DIR}/"
cat > "${PKG_DIR}/DEBIAN/control" << EOF
Package: rustfs-cli
Version: ${VERSION}
Section: utils
Priority: optional
Architecture: ${{ matrix.arch }}
Maintainer: RustFS Team <support@rustfs.com>
Description: Rust S3 CLI client for S3-compatible object storage
rc is a statically linked command-line client for RustFS, MinIO, AWS S3,
and other S3-compatible object storage services.
Homepage: https://github.com/rustfs/cli
EOF
fakeroot dpkg-deb --build "${PKG_DIR}"
DEB_FILE="${PKG_DIR}.deb"
echo "deb_file=${DEB_FILE}" >> "$GITHUB_OUTPUT"
ls -lh "${DEB_FILE}"
- name: Build RPM package
id: rpm
shell: bash
run: |
set -euo pipefail
VERSION="${{ needs.resolve.outputs.package_version }}"
fpm -s dir -t rpm \
--name rustfs-cli \
--version "${VERSION}" \
--iteration 1 \
--architecture "${{ matrix.rpm_arch }}" \
--maintainer 'RustFS Team <support@rustfs.com>' \
--description 'Rust S3 CLI client for S3-compatible object storage' \
--url 'https://github.com/rustfs/cli' \
--license 'MIT OR Apache-2.0' \
./pkg-root/usr/bin/rc=/usr/bin/rc \
./pkg-root/usr/share/doc/rustfs-cli/LICENSE-MIT=/usr/share/doc/rustfs-cli/LICENSE-MIT \
./pkg-root/usr/share/doc/rustfs-cli/LICENSE-APACHE=/usr/share/doc/rustfs-cli/LICENSE-APACHE \
./pkg-root/usr/share/doc/rustfs-cli/README.md=/usr/share/doc/rustfs-cli/README.md \
./pkg-root/usr/share/bash-completion/completions/rc=/usr/share/bash-completion/completions/rc \
./pkg-root/usr/share/zsh/site-functions/_rc=/usr/share/zsh/site-functions/_rc \
./pkg-root/usr/share/fish/vendor_completions.d/rc.fish=/usr/share/fish/vendor_completions.d/rc.fish
RPM_FILE=$(find . -maxdepth 1 -type f -name 'rustfs-cli-*.rpm' -print -quit)
if [[ -z "${RPM_FILE}" ]]; then
echo "RPM build failed"
exit 1
fi
echo "rpm_file=${RPM_FILE}" >> "$GITHUB_OUTPUT"
ls -lh "${RPM_FILE}"
- name: Verify package metadata
env:
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
RPM_FILE: ${{ steps.rpm.outputs.rpm_file }}
shell: bash
run: |
set -euo pipefail
if dpkg-deb -f "${DEB_FILE}" Depends | grep -Eiq '(^|[, ])libc6|glibc'; then
echo "DEB unexpectedly declares a glibc dependency"
exit 1
fi
if rpm -qp --requires "${RPM_FILE}" | grep -Eiq 'glibc|libc\.so\.6|ld-linux'; then
echo "RPM unexpectedly declares a dynamic libc dependency"
exit 1
fi
dpkg-deb --contents "${DEB_FILE}" | grep -F '/usr/bin/rc'
rpm -qpl "${RPM_FILE}" | grep -F '/usr/bin/rc'
- name: Upload package artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: packages-${{ matrix.arch }}
path: |
*.deb
*.rpm
if-no-files-found: error
retention-days: 30
publish-github:
name: Publish packages to GitHub Release
needs: [resolve, package]
if: needs.resolve.result == 'success' && needs.package.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
actions: read
steps:
- name: Download package artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: packages-*
path: ./packages
github-token: ${{ github.token }}
run-id: ${{ github.run_id }}
merge-multiple: true
- name: Upload packages and merge checksums
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
TAG: ${{ needs.resolve.outputs.tag }}
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
package_files=(./packages/*.deb ./packages/*.rpm)
if (( ${#package_files[@]} != 4 )); then
echo "Expected four package files, found ${#package_files[@]}"
printf '%s\n' ./packages/* || true
exit 1
fi
checksum_dir=$(mktemp -d)
checksum_file="${checksum_dir}/SHA256SUMS"
if ! gh release download "${TAG}" \
--repo "${REPOSITORY}" \
--pattern 'SHA256SUMS' \
--dir "${checksum_dir}" \
--clobber; then
asset_count=$(gh api \
"repos/${REPOSITORY}/releases/tags/${TAG}" \
--jq '[.assets[] | select(.name == "SHA256SUMS")] | length')
if [[ "${asset_count}" != "0" ]]; then
echo "Unable to download the existing SHA256SUMS asset"
exit 1
fi
: > "${checksum_file}"
fi
if [[ ! -f "${checksum_file}" ]]; then
echo "SHA256SUMS download did not produce a file"
exit 1
fi
for file in "${package_files[@]}"; do
base=$(basename -- "${file}")
legacy_base="${base//\~/.}"
gh release upload "${TAG}" "${file}" --repo "${REPOSITORY}" --clobber
awk -v name="${base}" -v legacy="${legacy_base}" '
NF >= 2 {
candidate = $2
sub(/^\*/, "", candidate)
if (candidate == name || candidate == legacy) next
}
{ print }
' "${checksum_file}" > "${checksum_file}.next"
mv "${checksum_file}.next" "${checksum_file}"
checksum=$(sha256sum -- "${file}" | awk '{print $1}')
printf '%s %s\n' "${checksum}" "${base}" >> "${checksum_file}"
done
sort -k2,2 "${checksum_file}" -o "${checksum_file}"
gh release upload "${TAG}" "${checksum_file}" --repo "${REPOSITORY}" --clobber
publish-r2:
name: Publish packages to Cloudflare R2
needs: [resolve, package]
if: needs.resolve.result == 'success' && needs.package.result == 'success'
continue-on-error: true
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
actions: read
steps:
- name: Download package artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: packages-*
path: ./packages
github-token: ${{ github.token }}
run-id: ${{ github.run_id }}
merge-multiple: true
- name: Upload packages and checksums
env:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
AWS_EC2_METADATA_DISABLED: true
VERSION: ${{ needs.resolve.outputs.version }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${R2_ACCESS_KEY_ID}" || -z "${R2_SECRET_ACCESS_KEY}" || -z "${R2_ENDPOINT}" || -z "${R2_BUCKET}" ]]; then
echo "R2 credentials missing, skipping upload"
exit 0
fi
if ! command -v aws >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y awscli
fi
export AWS_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID}"
export AWS_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY}"
export AWS_DEFAULT_REGION="auto"
export AWS_REQUEST_CHECKSUM_CALCULATION="when_required"
export AWS_RESPONSE_CHECKSUM_VALIDATION="when_required"
shopt -s nullglob
package_files=(./packages/*.deb ./packages/*.rpm)
if (( ${#package_files[@]} != 4 )); then
echo "Expected four package files, found ${#package_files[@]}"
exit 1
fi
checksum_file=./packages/SHA256SUMS
(
cd ./packages
sha256sum -- *.deb *.rpm
) > "${checksum_file}"
VERSION_PATH="s3://${R2_BUCKET}/artifacts/rustfs-cli/packages/release/${VERSION}/"
LATEST_PATH="s3://${R2_BUCKET}/artifacts/rustfs-cli/packages/latest/"
for file in "${package_files[@]}" "${checksum_file}"; do
aws s3 cp "${file}" "${VERSION_PATH}" --endpoint-url "${R2_ENDPOINT}" --only-show-errors
aws s3 cp "${file}" "${LATEST_PATH}" --endpoint-url "${R2_ENDPOINT}" --only-show-errors
done
summary:
name: Summary
needs: [resolve, package, publish-github, publish-r2]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
actions: read
steps:
- name: Print summary
env:
VERSION: ${{ needs.resolve.outputs.version }}
BUILD_RUN_ID: ${{ needs.resolve.outputs.build_run_id }}
PACKAGE_RESULT: ${{ needs.package.result }}
GITHUB_RESULT: ${{ needs.publish-github.result }}
R2_RESULT: ${{ needs.publish-r2.result }}
shell: bash
run: |
{
echo "## Package Summary"
echo ""
echo "| Item | Value |"
echo "|------|-------|"
echo "| Version | ${VERSION} |"
echo "| Build Run | #${BUILD_RUN_ID} |"
echo "| Package Status | ${PACKAGE_RESULT} |"
echo "| GitHub Release Status | ${GITHUB_RESULT} |"
echo "| R2 Status | ${R2_RESULT} |"
} >> "$GITHUB_STEP_SUMMARY"