diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..ca3b800 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,359 @@ +name: Package DEB/RPM + +permissions: + contents: write + actions: read + +on: + workflow_run: + workflows: ["Build and Release"] + types: [completed] + workflow_dispatch: + inputs: + tag: + description: "Release tag to package (for example v0.1.24 or v0.1.24-rc.1)" + required: false + type: string + build_run_id: + description: "Build and Release workflow run ID to package" + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.event.inputs.tag || github.run_id }} + cancel-in-progress: true + +env: + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }} + +jobs: + resolve: + name: Resolve Build + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch != 'main') + runs-on: ubuntu-latest + timeout-minutes: 10 + 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 }} + steps: + - name: Resolve build run + id: resolve + env: + GH_TOKEN: ${{ github.token }} + INPUT_TAG: ${{ github.event.inputs.tag }} + INPUT_RUN_ID: ${{ github.event.inputs.build_run_id }} + shell: bash + run: | + set -euo pipefail + + if [[ "${{ github.event_name }}" == "workflow_run" ]]; then + TAG="${HEAD_BRANCH}" + elif [[ -n "${INPUT_TAG}" ]]; then + TAG="${INPUT_TAG}" + else + TAG="" + fi + + BUILD_RUN_ID="" + if [[ -n "${INPUT_RUN_ID}" ]]; then + BUILD_RUN_ID="${INPUT_RUN_ID}" + echo "Using explicit build run ID: ${BUILD_RUN_ID}" + elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then + BUILD_RUN_ID="${WORKFLOW_RUN_ID}" + echo "Using triggering workflow run: ${BUILD_RUN_ID}" + elif [[ -n "${TAG}" ]]; then + echo "Looking for release workflow run for tag: ${TAG}" + BUILD_RUN_ID=$(gh api \ + "repos/${{ github.repository }}/actions/workflows/release.yml/runs?event=push&status=success&per_page=100" \ + --jq ".workflow_runs[] | select(.head_branch == \"${TAG}\") | .id" 2>/dev/null | head -1 || echo "") + + if [[ -z "${BUILD_RUN_ID}" || "${BUILD_RUN_ID}" == "null" ]]; then + echo "No successful release workflow run found for tag: ${TAG}" + exit 1 + fi + else + echo "A tag or build_run_id is required for manual packaging" + exit 1 + fi + + if [[ -z "${TAG}" ]]; then + TAG=$(gh api "repos/${{ github.repository }}/actions/runs/${BUILD_RUN_ID}" --jq '.head_branch' 2>/dev/null || echo "") + fi + + if [[ -z "${TAG}" || "${TAG}" == "null" ]]; then + echo "Unable to resolve release tag for workflow run ${BUILD_RUN_ID}" + exit 1 + fi + + if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then + echo "Resolved tag is not a release tag: ${TAG}" + 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}" + } >> "$GITHUB_OUTPUT" + + echo "Resolved version: ${TAG}" + echo "Resolved package version: ${PACKAGE_VERSION}" + echo "Resolved workflow run: ${BUILD_RUN_ID}" + + package: + name: Package (${{ matrix.arch }}) + needs: resolve + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + rpm_arch: x86_64 + artifact_name: rustfs-cli-linux-amd64-gnu + - arch: arm64 + rpm_arch: aarch64 + artifact_name: rustfs-cli-linux-arm64-gnu + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.resolve.outputs.tag }} + + - 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 -name '*.tar.gz' -type f | head -1) + if [[ -z "${TAR_FILE}" ]]; then + echo "No Linux binary archive found" + ls -la ./binary-artifact/ || true + exit 1 + fi + + mkdir -p ./binary-extract + tar -xzf "${TAR_FILE}" -C ./binary-extract + + mkdir -p ./pkg-root/usr/bin + install -m 755 ./binary-extract/rc ./pkg-root/usr/bin/rc + chmod 755 ./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" + ls -la ./completions-artifact/ || 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 + + - 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 }} + Depends: libc6 (>= 2.31) + Maintainer: RustFS Team + Description: Rust S3 CLI client for S3-compatible object storage + rc is a 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 }}" \ + --depends 'glibc >= 2.31' \ + --maintainer 'RustFS Team ' \ + --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=$(ls -1 rustfs-cli-*.rpm 2>/dev/null | head -1) + if [[ -z "${RPM_FILE}" ]]; then + echo "RPM build failed" + exit 1 + fi + + echo "rpm_file=${RPM_FILE}" >> "$GITHUB_OUTPUT" + ls -lh "${RPM_FILE}" + + - name: Upload package artifacts + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: packages-${{ matrix.arch }} + path: | + *.deb + *.rpm + retention-days: 30 + + - name: Upload packages to Cloudflare R2 + 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 + 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" + + VERSION="${{ needs.resolve.outputs.version }}" + VERSION_PATH="s3://${R2_BUCKET}/artifacts/rustfs-cli/packages/release/${VERSION}/" + LATEST_PATH="s3://${R2_BUCKET}/artifacts/rustfs-cli/packages/latest/" + + for file in "${{ steps.deb.outputs.deb_file }}" "${{ steps.rpm.outputs.rpm_file }}"; do + if [[ -n "${file}" && -f "${file}" ]]; then + 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 + fi + done + + - name: Upload packages to GitHub Release + if: needs.resolve.outputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + + TAG="${{ needs.resolve.outputs.tag }}" + CHECKSUM_FILE="$(mktemp)" + gh release download "${TAG}" -p 'SHA256SUMS' -D "$(dirname "${CHECKSUM_FILE}")" --clobber 2>/dev/null || true + if [[ -f "$(dirname "${CHECKSUM_FILE}")/SHA256SUMS" ]]; then + mv "$(dirname "${CHECKSUM_FILE}")/SHA256SUMS" "${CHECKSUM_FILE}" + else + : > "${CHECKSUM_FILE}" + fi + + for file in "${{ steps.deb.outputs.deb_file }}" "${{ steps.rpm.outputs.rpm_file }}"; do + if [[ -n "${file}" && -f "${file}" ]]; then + base=$(basename "${file}") + github_base="${base//\~/.}" + gh release upload "${TAG}" "${file}" --clobber + grep -Fv -- "${base}" "${CHECKSUM_FILE}" > "${CHECKSUM_FILE}.tmp" || true + grep -Fv -- "${github_base}" "${CHECKSUM_FILE}.tmp" > "${CHECKSUM_FILE}.next" || true + mv "${CHECKSUM_FILE}.next" "${CHECKSUM_FILE}" + checksum=$(sha256sum "${file}" | awk '{print $1}') + printf '%s %s\n' "${checksum}" "${github_base}" >> "${CHECKSUM_FILE}" + fi + done + + mv "${CHECKSUM_FILE}" "$(dirname "${CHECKSUM_FILE}")/SHA256SUMS" + gh release upload "${TAG}" "$(dirname "${CHECKSUM_FILE}")/SHA256SUMS" --clobber + + summary: + name: Summary + needs: [resolve, package] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Print summary + shell: bash + run: | + echo "## Package Summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Item | Value |" >> "$GITHUB_STEP_SUMMARY" + echo "|------|-------|" >> "$GITHUB_STEP_SUMMARY" + echo "| Version | ${{ needs.resolve.outputs.version }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Build Run | #${{ needs.resolve.outputs.build_run_id }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Package Status | ${{ needs.package.result }} |" >> "$GITHUB_STEP_SUMMARY" \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 93efc62..e81ca5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1656,9 +1656,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", diff --git a/README.md b/README.md index acb6a87..12ec65a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,11 @@ Download the appropriate binary for your platform from the [Releases](https://gi On Linux, use the default `linux-amd64` / `linux-arm64` artifacts for maximum compatibility (`musl` static build). If you specifically need glibc-linked builds, use `linux-amd64-gnu` / `linux-arm64-gnu`. +### DEB/RPM Packages + +Debian and RPM packages are published as GitHub release assets for each tagged release. +Download the appropriate `.deb` or `.rpm` package from the [Releases](https://github.com/rustfs/cli/releases) page and install it with your system package manager. + ### Homebrew (macOS/Linux) ```bash diff --git a/crates/cli/src/commands/anonymous.rs b/crates/cli/src/commands/anonymous.rs index b78c6e0..cacb727 100644 --- a/crates/cli/src/commands/anonymous.rs +++ b/crates/cli/src/commands/anonymous.rs @@ -675,7 +675,7 @@ fn build_policy(level: &AccessLevel, bucket: &str, prefix: &str) -> String { let sanitized = prefix.trim_end_matches('/'); condition = serde_json::json!({ "StringLike": { - "s3:prefix": [format!("{sanitized}"), format!("{sanitized}/*"), format!("{sanitized}*")] + "s3:prefix": [sanitized.to_string(), format!("{sanitized}/*"), format!("{sanitized}*")] } }); } diff --git a/crates/cli/src/commands/mb.rs b/crates/cli/src/commands/mb.rs index fa23e17..5b71ea5 100644 --- a/crates/cli/src/commands/mb.rs +++ b/crates/cli/src/commands/mb.rs @@ -246,6 +246,7 @@ pub async fn execute(args: MbArgs, output_config: OutputConfig) -> ExitCode { } } +#[allow(clippy::result_large_err)] async fn run_bucket_creation( store: &S, bucket: &str, @@ -367,6 +368,7 @@ async fn run_bucket_creation( Ok(data) } +#[allow(clippy::result_large_err)] async fn verify_object_lock( store: &S, bucket: &str, diff --git a/crates/cli/src/commands/rm.rs b/crates/cli/src/commands/rm.rs index 07cec0c..672c524 100644 --- a/crates/cli/src/commands/rm.rs +++ b/crates/cli/src/commands/rm.rs @@ -417,6 +417,7 @@ fn failure_upload_id(failure: &RmIncompleteFailure) -> &str { .unwrap_or_default() } +#[allow(clippy::result_large_err)] async fn process_incomplete_path( path_str: &str, args: &RmArgs, diff --git a/crates/cli/src/commands/watch.rs b/crates/cli/src/commands/watch.rs index a2915c0..f7b2a1f 100644 --- a/crates/cli/src/commands/watch.rs +++ b/crates/cli/src/commands/watch.rs @@ -602,7 +602,7 @@ mod tests { use std::sync::{Arc, Mutex}; use async_trait::async_trait; - use futures::{StreamExt as _, stream}; + use futures::stream; use rc_core::admin::{CapabilityEntry, ClusterSnapshotMetadata}; use rc_core::{Result as CoreResult, WatchSource, WatchStream}; diff --git a/crates/s3/src/watch.rs b/crates/s3/src/watch.rs index 64d3b3e..2de054c 100644 --- a/crates/s3/src/watch.rs +++ b/crates/s3/src/watch.rs @@ -454,7 +454,7 @@ fn safe_error_component(value: String) -> Option { mod tests { use std::collections::HashMap; - use futures::{StreamExt as _, stream}; + use futures::stream; use rc_core::{Alias, WatchFrame}; use super::*;