From 5434a9eb1b78323c69e239e492fab61133f473f2 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Sat, 15 Nov 2025 10:47:24 -0300 Subject: [PATCH 1/7] Track docker image size changes --- .../docker-image-size-tracker/README.md | 119 +++++++ .../docker-image-size-tracker/action.yml | 328 ++++++++++++++++++ .github/workflows/ci.yml | 24 +- 3 files changed, 469 insertions(+), 2 deletions(-) create mode 100644 .github/actions/docker-image-size-tracker/README.md create mode 100644 .github/actions/docker-image-size-tracker/action.yml diff --git a/.github/actions/docker-image-size-tracker/README.md b/.github/actions/docker-image-size-tracker/README.md new file mode 100644 index 0000000000000..ad1738ed1749e --- /dev/null +++ b/.github/actions/docker-image-size-tracker/README.md @@ -0,0 +1,119 @@ +# Docker Image Size Tracker + +Automatically tracks and reports Docker image sizes in Pull Requests. + +## Features + +- 📊 **Automatic Tracking**: Measures all service image sizes after build +- 📈 **PR Comments**: Posts size comparison vs `develop` baseline +- đŸŽ¯ **Multi-Service**: Tracks all microservices independently +- 🔔 **Visual Reports**: Tables with size changes and percentages + +## How It Works + +1. After Docker images are built and published in CI +2. Action measures sizes using `skopeo` (no image pull needed) +3. Compares against `develop` baseline +4. Posts/updates PR comment with detailed report + +## Example Output + +```markdown +đŸ“Ļ Docker Image Size Report + +📈 Summary 🔴 +2.3% + +| Metric | Value | +|--------|-------| +| Current Total | 1.23 GB | +| Baseline Total | 1.20 GB | +| Difference | +27.65 MB (+2.30%) | + +📊 Service Details + +| Service | Current | Baseline | Change | +|---------|---------|----------|--------| +| 📈 rocketchat | 850 MB | 830 MB | +20 MB | +| 📉 auth-service | 120 MB | 125 MB | -5 MB | +``` + +## Usage + +Already integrated in `.github/workflows/ci.yml`: + +```yaml +- name: Track Docker image sizes + uses: ./.github/actions/docker-image-size-tracker + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + registry: ghcr.io + repository: ${{ needs.release-versions.outputs.lowercase-repo }} + tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + baseline-tag: develop +``` + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `github-token` | Token for PR comments | Yes | - | +| `registry` | Container registry | No | `ghcr.io` | +| `repository` | Repository name | Yes | - | +| `tag` | Image tag to measure | Yes | - | +| `baseline-tag` | Baseline tag to compare | No | `develop` | +| `services` | JSON array of services | No | All services | + +## Outputs + +| Output | Description | +|--------|-------------| +| `total-size` | Total size in bytes | +| `size-diff` | Size difference in bytes | +| `size-diff-percent` | Size difference percentage | + +## Requirements + +- `skopeo` (installed automatically) +- `jq` (installed automatically) +- Images must be pushed to registry before tracking + +## Customization + +### Track Specific Services + +```yaml +- uses: ./.github/actions/docker-image-size-tracker + with: + services: '["rocketchat","authorization-service"]' +``` + +### Use Different Baseline + +```yaml +- uses: ./.github/actions/docker-image-size-tracker + with: + baseline-tag: 'latest' +``` + +## Troubleshooting + +### Images not found + +Ensure the job runs after images are published: + +```yaml +needs: [build-gh-docker-publish, release-versions] +``` + +### No PR comment appears + +Check that the workflow has permissions: + +```yaml +permissions: + pull-requests: write +``` + +## License + +Same as Rocket.Chat project. diff --git a/.github/actions/docker-image-size-tracker/action.yml b/.github/actions/docker-image-size-tracker/action.yml new file mode 100644 index 0000000000000..164394e57c519 --- /dev/null +++ b/.github/actions/docker-image-size-tracker/action.yml @@ -0,0 +1,328 @@ +name: 'Docker Image Size Tracker' +description: 'Track and report Docker image sizes in Pull Requests' +author: 'Rocket.Chat' + +inputs: + github-token: + description: 'GitHub token for commenting on PRs' + required: true + registry: + description: 'Container registry (e.g., ghcr.io)' + required: false + default: 'ghcr.io' + repository: + description: 'Repository name (e.g., rocketchat)' + required: true + tag: + description: 'Image tag to measure' + required: true + baseline-tag: + description: 'Baseline tag to compare against' + required: false + default: 'develop' + services: + description: 'JSON array of service names to track' + required: false + default: '["rocketchat","authorization-service","account-service","ddp-streamer-service","presence-service","stream-hub-service","queue-worker-service","omnichannel-transcript-service"]' + +outputs: + total-size: + description: 'Total size in bytes' + value: ${{ steps.measure.outputs.total-size }} + size-diff: + description: 'Size difference in bytes' + value: ${{ steps.compare.outputs.size-diff }} + size-diff-percent: + description: 'Size difference percentage' + value: ${{ steps.compare.outputs.size-diff-percent }} + +runs: + using: 'composite' + steps: + - name: Install dependencies + shell: bash + run: | + if ! command -v skopeo &> /dev/null; then + sudo apt-get update -qq + sudo apt-get install -y skopeo jq + fi + + - name: Measure image sizes + id: measure + shell: bash + run: | + SERVICES='${{ inputs.services }}' + REGISTRY="${{ inputs.registry }}" + ORG="${{ inputs.repository }}" + TAG="${{ inputs.tag }}" + + echo "Measuring images: $REGISTRY/$ORG/*:$TAG" + + declare -A sizes + total=0 + + for service in $(echo "$SERVICES" | jq -r '.[]'); do + if [[ "$service" == "rocketchat" ]]; then + image_name="rocket.chat" + else + image_name="$service" + fi + + image="$REGISTRY/$ORG/$image_name:$TAG" + echo "Measuring $image..." + + size=0 + if manifest=$(skopeo inspect --raw "docker://$image" 2>/dev/null); then + # Handle multi-arch manifests + if echo "$manifest" | jq -e '.manifests' > /dev/null 2>&1; then + digest=$(echo "$manifest" | jq -r '.manifests[] | select(.platform.architecture=="amd64") | .digest' | head -1) + if [[ -n "$digest" ]]; then + manifest=$(skopeo inspect --raw "docker://$image@$digest" 2>/dev/null) + fi + fi + + # Sum layer sizes + size=$(echo "$manifest" | jq '[.layers[]?.size // 0] | add // 0') + config_size=$(echo "$manifest" | jq '.config.size // 0') + size=$((size + config_size)) + fi + + echo " → $service: $size bytes" + sizes[$service]=$size + total=$((total + size)) + done + + echo "total-size=$total" >> $GITHUB_OUTPUT + + # Save to JSON + echo "{" > current-sizes.json + echo " \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"," >> current-sizes.json + echo " \"tag\": \"$TAG\"," >> current-sizes.json + echo " \"total\": $total," >> current-sizes.json + echo " \"services\": {" >> current-sizes.json + + first=true + for service in $(echo "$SERVICES" | jq -r '.[]'); do + if [[ "$first" == "true" ]]; then + first=false + else + echo "," >> current-sizes.json + fi + echo " \"$service\": ${sizes[$service]}" >> current-sizes.json + done + + echo " }" >> current-sizes.json + echo "}" >> current-sizes.json + + - name: Measure baseline + id: baseline + shell: bash + continue-on-error: true + run: | + SERVICES='${{ inputs.services }}' + REGISTRY="${{ inputs.registry }}" + ORG="${{ inputs.repository }}" + TAG="${{ inputs.baseline-tag }}" + + echo "Measuring baseline: $REGISTRY/$ORG/*:$TAG" + + declare -A sizes + total=0 + + for service in $(echo "$SERVICES" | jq -r '.[]'); do + if [[ "$service" == "rocketchat" ]]; then + image_name="rocket.chat" + else + image_name="$service" + fi + + image="$REGISTRY/$ORG/$image_name:$TAG" + + size=0 + if manifest=$(skopeo inspect --raw "docker://$image" 2>/dev/null); then + if echo "$manifest" | jq -e '.manifests' > /dev/null 2>&1; then + digest=$(echo "$manifest" | jq -r '.manifests[] | select(.platform.architecture=="amd64") | .digest' | head -1) + if [[ -n "$digest" ]]; then + manifest=$(skopeo inspect --raw "docker://$image@$digest" 2>/dev/null) + fi + fi + + size=$(echo "$manifest" | jq '[.layers[]?.size // 0] | add // 0') + config_size=$(echo "$manifest" | jq '.config.size // 0') + size=$((size + config_size)) + fi + + sizes[$service]=$size + total=$((total + size)) + done + + echo "baseline-total=$total" >> $GITHUB_OUTPUT + + echo "{" > baseline-sizes.json + echo " \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"," >> baseline-sizes.json + echo " \"tag\": \"$TAG\"," >> baseline-sizes.json + echo " \"total\": $total," >> baseline-sizes.json + echo " \"services\": {" >> baseline-sizes.json + + first=true + for service in $(echo "$SERVICES" | jq -r '.[]'); do + if [[ "$first" == "true" ]]; then + first=false + else + echo "," >> baseline-sizes.json + fi + echo " \"$service\": ${sizes[$service]}" >> baseline-sizes.json + done + + echo " }" >> baseline-sizes.json + echo "}" >> baseline-sizes.json + + - name: Compare and generate report + id: compare + shell: bash + run: | + current_total=$(jq -r '.total' current-sizes.json) + + if [[ ! -f baseline-sizes.json ]]; then + echo "No baseline available" + echo "size-diff=0" >> $GITHUB_OUTPUT + echo "size-diff-percent=0" >> $GITHUB_OUTPUT + + cat > report.md << 'EOF' + # đŸ“Ļ Docker Image Size Report + + **Status:** First measurement - no baseline for comparison + + **Total Size:** $(numfmt --to=iec-i --suffix=B $current_total) + EOF + exit 0 + fi + + baseline_total=$(jq -r '.total' baseline-sizes.json) + diff=$((current_total - baseline_total)) + + if [[ $baseline_total -gt 0 ]]; then + percent=$(awk "BEGIN {printf \"%.2f\", ($diff / $baseline_total) * 100}") + else + percent=0 + fi + + echo "size-diff=$diff" >> $GITHUB_OUTPUT + echo "size-diff-percent=$percent" >> $GITHUB_OUTPUT + + # Generate report + if (( $(echo "$diff > 0" | bc -l) )); then + emoji="📈" + badge="![](https://img.shields.io/badge/size-+${percent}%25-red)" + sign="+" + elif (( $(echo "$diff < 0" | bc -l) )); then + emoji="📉" + badge="![](https://img.shields.io/badge/size-${percent}%25-green)" + sign="" + else + emoji="âžĄī¸" + badge="![](https://img.shields.io/badge/size-unchanged-gray)" + sign="" + fi + + cat > report.md << EOF + # đŸ“Ļ Docker Image Size Report + + ## $emoji Summary $badge + + | Metric | Value | + |--------|-------| + | **Current Total** | $(numfmt --to=iec-i --suffix=B $current_total) | + | **Baseline Total** | $(numfmt --to=iec-i --suffix=B $baseline_total) | + | **Difference** | **${sign}$(numfmt --to=iec-i --suffix=B ${diff#-})** (${sign}${percent}%) | + + ## 📊 Service Details + + | Service | Current | Baseline | Change | + |---------|---------|----------|--------| + EOF + + SERVICES='${{ inputs.services }}' + for service in $(echo "$SERVICES" | jq -r '.[]'); do + current=$(jq -r ".services.\"$service\"" current-sizes.json) + baseline=$(jq -r ".services.\"$service\"" baseline-sizes.json) + service_diff=$((current - baseline)) + + if [[ $service_diff -gt 0 ]]; then + icon="📈" + sign="+" + elif [[ $service_diff -lt 0 ]]; then + icon="📉" + sign="" + else + icon="âžĄī¸" + sign="" + fi + + echo "| $icon **$service** | $(numfmt --to=iec-i --suffix=B $current) | $(numfmt --to=iec-i --suffix=B $baseline) | ${sign}$(numfmt --to=iec-i --suffix=B ${service_diff#-}) |" >> report.md + done + + cat >> report.md << EOF + +
+ â„šī¸ About this report + + This report compares Docker image sizes from this build against the \`${{ inputs.baseline-tag }}\` baseline. + + - **Tag:** \`${{ inputs.tag }}\` + - **Baseline:** \`${{ inputs.baseline-tag }}\` + - **Timestamp:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") + +
+ EOF + + - name: Comment on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{ inputs.github-token }} + script: | + const fs = require('fs'); + + if (!fs.existsSync('report.md')) { + console.log('No report found, skipping comment'); + return; + } + + const report = fs.readFileSync('report.md', 'utf8'); + + // Find existing comment + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + const botComment = comments.data.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('đŸ“Ļ Docker Image Size Report') + ); + + const commentBody = report + '\n\n---\n*Updated: ' + new Date().toUTCString() + '*'; + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: commentBody + }); + console.log('Updated existing comment'); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: commentBody + }); + console.log('Created new comment'); + } + +branding: + icon: 'package' + color: 'blue' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ccc1f44e5cc6..1f47806f9dcd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -436,6 +436,26 @@ jobs: ${refs[@]} done + track-image-sizes: + name: đŸ“Ļ Track Image Sizes + needs: [build-gh-docker-publish, release-versions] + runs-on: ubuntu-24.04 + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/develop' + permissions: + pull-requests: write + + steps: + - uses: actions/checkout@v5 + + - name: Track Docker image sizes + uses: ./.github/actions/docker-image-size-tracker + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + registry: ghcr.io + repository: ${{ needs.release-versions.outputs.lowercase-repo }} + tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + baseline-tag: develop + checks: needs: [release-versions, packages-build] @@ -775,7 +795,7 @@ jobs: # sudo apt-get update -y # sudo apt-get install -y skopeo - + # 'develop' or 'tag' DOCKER_TAG=$GITHUB_REF_NAME @@ -807,7 +827,7 @@ jobs: # get first tag as primary PRIMARY="${TAGS[0]}" - + for service_dir in /tmp/digests/*; do [[ -d "$service_dir" ]] || continue service="$(basename "$service_dir")" From 40778f9f82ad821a1d2c88adbcc4483b62fa4626 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Sat, 15 Nov 2025 11:03:21 -0300 Subject: [PATCH 2/7] Keep history --- .../docker-image-size-tracker/README.md | 62 +++++++- .../docker-image-size-tracker/action.yml | 137 +++++++++++++++++- .github/workflows/ci.yml | 1 + 3 files changed, 194 insertions(+), 6 deletions(-) diff --git a/.github/actions/docker-image-size-tracker/README.md b/.github/actions/docker-image-size-tracker/README.md index ad1738ed1749e..d7f9e018d6246 100644 --- a/.github/actions/docker-image-size-tracker/README.md +++ b/.github/actions/docker-image-size-tracker/README.md @@ -1,20 +1,25 @@ # Docker Image Size Tracker -Automatically tracks and reports Docker image sizes in Pull Requests. +Automatically tracks and reports Docker image sizes in Pull Requests with historical trend analysis. ## Features - 📊 **Automatic Tracking**: Measures all service image sizes after build - 📈 **PR Comments**: Posts size comparison vs `develop` baseline +- 📉 **Historical Trends**: Tracks size evolution over time with visual charts - đŸŽ¯ **Multi-Service**: Tracks all microservices independently -- 🔔 **Visual Reports**: Tables with size changes and percentages +- 🔔 **Visual Reports**: Tables, graphs, and statistics +- 💾 **Persistent Storage**: Stores history in Git orphan branch ## How It Works 1. After Docker images are built and published in CI 2. Action measures sizes using `skopeo` (no image pull needed) 3. Compares against `develop` baseline -4. Posts/updates PR comment with detailed report +4. Loads historical data from `image-size-history` branch +5. Generates trend chart showing evolution + current PR impact +6. Posts/updates PR comment with detailed report +7. On merge to `develop`, saves measurement to history ## Example Output @@ -35,6 +40,16 @@ Automatically tracks and reports Docker image sizes in Pull Requests. |---------|---------|----------|--------| | 📈 rocketchat | 850 MB | 830 MB | +20 MB | | 📉 auth-service | 120 MB | 125 MB | -5 MB | + +📈 Historical Trend + +[Mermaid chart showing last 30 builds + current PR] + +Statistics (last 30 builds): +- 📊 Average: 1.19 GB +- âŦ‡ī¸ Minimum: 1.15 GB +- âŦ†ī¸ Maximum: 1.25 GB +- đŸŽ¯ Current PR: 1.23 GB ``` ## Usage @@ -71,11 +86,46 @@ Already integrated in `.github/workflows/ci.yml`: | `size-diff` | Size difference in bytes | | `size-diff-percent` | Size difference percentage | +## Historical Data + +The action stores historical measurements in a Git orphan branch called `image-size-history`: + +- **Automatic Storage**: When changes are merged to `develop`, measurements are saved +- **Retention**: Last 30 measurements are used for trend analysis +- **Format**: JSON files with timestamps and commit references +- **Branch Structure**: + ``` + image-size-history/ + ├── README.md + └── history/ + ├── 20241115-143022.json + ├── 20241115-150145.json + └── ... + ``` + +### Manual History Management + +View history branch: +```bash +git fetch origin image-size-history +git checkout image-size-history +``` + +Clean old measurements: +```bash +git checkout image-size-history +rm history/old-*.json +git commit -m "Clean old measurements" +git push origin image-size-history +``` + ## Requirements - `skopeo` (installed automatically) - `jq` (installed automatically) +- `bc` (installed automatically) - Images must be pushed to registry before tracking +- Write permissions to repository (for history storage) ## Customization @@ -105,6 +155,12 @@ Ensure the job runs after images are published: needs: [build-gh-docker-publish, release-versions] ``` +### History not showing + +- History starts accumulating after first merge to `develop` +- Requires `contents: write` permission in workflow +- Check if `image-size-history` branch exists + ### No PR comment appears Check that the workflow has permissions: diff --git a/.github/actions/docker-image-size-tracker/action.yml b/.github/actions/docker-image-size-tracker/action.yml index 164394e57c519..9a1b797acd121 100644 --- a/.github/actions/docker-image-size-tracker/action.yml +++ b/.github/actions/docker-image-size-tracker/action.yml @@ -39,18 +39,25 @@ outputs: runs: using: 'composite' steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install dependencies shell: bash run: | if ! command -v skopeo &> /dev/null; then sudo apt-get update -qq - sudo apt-get install -y skopeo jq + sudo apt-get install -y skopeo jq bc fi - name: Measure image sizes id: measure shell: bash run: | + set -o xtrace + SERVICES='${{ inputs.services }}' REGISTRY="${{ inputs.registry }}" ORG="${{ inputs.repository }}" @@ -69,6 +76,7 @@ runs: fi image="$REGISTRY/$ORG/$image_name:$TAG" + image_not_tag="$REGISTRY/$ORG/$image_name" echo "Measuring $image..." size=0 @@ -77,7 +85,7 @@ runs: if echo "$manifest" | jq -e '.manifests' > /dev/null 2>&1; then digest=$(echo "$manifest" | jq -r '.manifests[] | select(.platform.architecture=="amd64") | .digest' | head -1) if [[ -n "$digest" ]]; then - manifest=$(skopeo inspect --raw "docker://$image@$digest" 2>/dev/null) + manifest=$(skopeo inspect --raw "docker://$image_not_tag@$digest" 2>/dev/null) fi fi @@ -137,13 +145,14 @@ runs: fi image="$REGISTRY/$ORG/$image_name:$TAG" + image_not_tag="$REGISTRY/$ORG/$image_name" size=0 if manifest=$(skopeo inspect --raw "docker://$image" 2>/dev/null); then if echo "$manifest" | jq -e '.manifests' > /dev/null 2>&1; then digest=$(echo "$manifest" | jq -r '.manifests[] | select(.platform.architecture=="amd64") | .digest' | head -1) if [[ -n "$digest" ]]; then - manifest=$(skopeo inspect --raw "docker://$image@$digest" 2>/dev/null) + manifest=$(skopeo inspect --raw "docker://$image_not_tag@$digest" 2>/dev/null) fi fi @@ -177,6 +186,59 @@ runs: echo " }" >> baseline-sizes.json echo "}" >> baseline-sizes.json + - name: Setup history storage + id: history + shell: bash + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + # Try to fetch history branch + if git ls-remote --heads origin image-size-history | grep -q image-size-history; then + git fetch origin image-size-history:image-size-history + git checkout image-size-history + else + # Create orphan branch for history + git checkout --orphan image-size-history + git rm -rf . 2>/dev/null || true + mkdir -p history + echo "# Image Size History" > README.md + echo "This branch stores historical image size data for tracking" >> README.md + git add README.md + git commit -m "Initialize image size history" + fi + + mkdir -p history + + - name: Load historical data + id: load-history + shell: bash + run: | + # Load last 30 measurements + echo "[]" > history-data.json + if [[ -d history ]]; then + jq -s '.' history/*.json 2>/dev/null | jq 'sort_by(.timestamp) | .[-30:]' > history-data.json || echo "[]" > history-data.json + fi + + count=$(jq 'length' history-data.json) + echo "Loaded $count historical measurements" + + - name: Save current measurement to history + if: github.ref == 'refs/heads/develop' + shell: bash + run: | + timestamp=$(date -u +%Y%m%d-%H%M%S) + commit_sha="${{ github.sha }}" + + # Add commit info to current measurement + jq --arg sha "$commit_sha" '. + {commit: $sha}' current-sizes.json > "history/${timestamp}.json" + + git add "history/${timestamp}.json" + git commit -m "Add measurement for ${timestamp} (${commit_sha:0:7})" + git push origin image-size-history + + echo "Saved measurement to history" + - name: Compare and generate report id: compare shell: bash @@ -262,6 +324,74 @@ runs: echo "| $icon **$service** | $(numfmt --to=iec-i --suffix=B $current) | $(numfmt --to=iec-i --suffix=B $baseline) | ${sign}$(numfmt --to=iec-i --suffix=B ${service_diff#-}) |" >> report.md done + # Generate historical trend chart + echo "" >> report.md + echo "## 📈 Historical Trend" >> report.md + echo "" >> report.md + + # Load history and generate mermaid chart + history_count=$(jq 'length' history-data.json) + + if [[ $history_count -gt 0 ]]; then + # Generate Mermaid chart data + x_labels="" + y_values="" + + while IFS= read -r line; do + timestamp=$(echo "$line" | jq -r '.timestamp') + total=$(echo "$line" | jq -r '.total') + + date_label=$(echo "$timestamp" | cut -d'T' -f1 | cut -d'-' -f2- | tr '-' '/') + size_gb=$(awk "BEGIN {printf \"%.2f\", $total / 1073741824}") + + if [[ -z "$x_labels" ]]; then + x_labels="\"$date_label\"" + y_values="$size_gb" + else + x_labels="$x_labels, \"$date_label\"" + y_values="$y_values, $size_gb" + fi + done < <(jq -c '.[]' history-data.json) + + # Add current PR as last point + current_date=$(date -u +"%m/%d") + current_gb=$(awk "BEGIN {printf \"%.2f\", $current_total / 1073741824}") + x_labels="$x_labels, \"$current_date (PR)\"" + y_values="$y_values, $current_gb" + + cat >> report.md << EOF + \`\`\`mermaid + %%{init: {'theme':'base'}}%% + xychart-beta + title "Total Image Size Evolution (Last 30 Builds + This PR)" + x-axis [$x_labels] + y-axis "Size (GB)" 0 --> 20 + line [$y_values] + \`\`\` + + EOF + + # Add summary stats + min_size=$(jq '[.[].total] | min' history-data.json) + max_size=$(jq '[.[].total] | max' history-data.json) + avg_size=$(jq '[.[].total] | add / length' history-data.json) + + cat >> report.md << EOF + **Statistics (last $history_count builds):** + - 📊 Average: $(numfmt --to=iec-i --suffix=B ${avg_size%.*}) + - âŦ‡ī¸ Minimum: $(numfmt --to=iec-i --suffix=B $min_size) + - âŦ†ī¸ Maximum: $(numfmt --to=iec-i --suffix=B $max_size) + - đŸŽ¯ Current PR: $(numfmt --to=iec-i --suffix=B $current_total) + + EOF + + else + cat >> report.md << 'EOF' + *No historical data available yet. History tracking starts after merging to develop.* + + EOF + fi + cat >> report.md << EOF
@@ -272,6 +402,7 @@ runs: - **Tag:** \`${{ inputs.tag }}\` - **Baseline:** \`${{ inputs.baseline-tag }}\` - **Timestamp:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") + - **Historical data points:** $history_count
EOF diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f47806f9dcd4..45de80cad490f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -443,6 +443,7 @@ jobs: if: github.event_name == 'pull_request' || github.ref == 'refs/heads/develop' permissions: pull-requests: write + contents: write steps: - uses: actions/checkout@v5 From 59da5716da99d956f8eb3c7ee15749dd3dcf48ae Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Sat, 15 Nov 2025 14:34:26 -0300 Subject: [PATCH 3/7] Improve logic --- .github/actions/build-docker/action.yml | 20 ++- .../docker-image-size-tracker/README.md | 133 +++++++++++++-- .../docker-image-size-tracker/action.yml | 156 +++++++++++------- .github/workflows/ci.yml | 21 +-- 4 files changed, 239 insertions(+), 91 deletions(-) diff --git a/.github/actions/build-docker/action.yml b/.github/actions/build-docker/action.yml index 32cec27d2099f..b00709834c389 100644 --- a/.github/actions/build-docker/action.yml +++ b/.github/actions/build-docker/action.yml @@ -113,16 +113,26 @@ runs: SERVICE_SUFFIX=${{ inputs.service == 'rocketchat' && inputs.type == 'coverage' && (github.event_name == 'release' || github.ref == 'refs/heads/develop') && '-cov' || '' }} - mkdir -p /tmp/digests/${{ inputs.service }}${SERVICE_SUFFIX}/${{ inputs.arch }} + mkdir -p /tmp/manifests/${{ inputs.service }}${SERVICE_SUFFIX}/${{ inputs.arch }} + + # Get digest and image info DIGEST=$(jq -r '.["${{ inputs.service }}"].["containerimage.digest"]' "/tmp/meta.json") - IMAGE_NO_TAG=$(echo "$IMAGE" | sed 's/:.*$//') - echo "${IMAGE_NO_TAG}@${DIGEST}" > "/tmp/digests/${{ inputs.service }}${SERVICE_SUFFIX}/${{ inputs.arch }}/digest.txt" + IMAGE_NO_TAG=$(echo "$IMAGE" | sed 's/:.*$//') + FULL_IMAGE="${IMAGE_NO_TAG}@${DIGEST}" + + echo "Inspecting image: $FULL_IMAGE" + + # Inspect the image and save complete manifest with sizes (using -v for verbose) + docker manifest inspect -v "$FULL_IMAGE" > "/tmp/manifests/${{ inputs.service }}${SERVICE_SUFFIX}/${{ inputs.arch }}/manifest.json" + + echo "Saved manifest to /tmp/manifests/${{ inputs.service }}${SERVICE_SUFFIX}/${{ inputs.arch }}/manifest.json" + cat "/tmp/manifests/${{ inputs.service }}${SERVICE_SUFFIX}/${{ inputs.arch }}/manifest.json" | jq '.' - uses: actions/upload-artifact@v4 if: inputs.publish-image == 'true' with: - name: digests-${{ inputs.service }}-${{ inputs.arch }}-${{ inputs.type }} - path: /tmp/digests + name: manifests-${{ inputs.service }}-${{ inputs.arch }}-${{ inputs.type }} + path: /tmp/manifests retention-days: 5 - name: Clean up temporary files diff --git a/.github/actions/docker-image-size-tracker/README.md b/.github/actions/docker-image-size-tracker/README.md index d7f9e018d6246..510387d3fc9f2 100644 --- a/.github/actions/docker-image-size-tracker/README.md +++ b/.github/actions/docker-image-size-tracker/README.md @@ -4,22 +4,48 @@ Automatically tracks and reports Docker image sizes in Pull Requests with histor ## Features -- 📊 **Automatic Tracking**: Measures all service image sizes after build +- 📊 **Automatic Tracking**: Captures image sizes during build (no remote inspection) - 📈 **PR Comments**: Posts size comparison vs `develop` baseline - 📉 **Historical Trends**: Tracks size evolution over time with visual charts - đŸŽ¯ **Multi-Service**: Tracks all microservices independently - 🔔 **Visual Reports**: Tables, graphs, and statistics - 💾 **Persistent Storage**: Stores history in Git orphan branch +- ⚡ **Fast**: Reads sizes from build artifacts instead of remote registry ## How It Works -1. After Docker images are built and published in CI -2. Action measures sizes using `skopeo` (no image pull needed) -3. Compares against `develop` baseline -4. Loads historical data from `image-size-history` branch -5. Generates trend chart showing evolution + current PR impact -6. Posts/updates PR comment with detailed report -7. On merge to `develop`, saves measurement to history +1. During Docker build, image manifest is inspected and saved to artifacts +2. Manifest contains digest, config size, and all layer sizes +3. After images are published in CI, the tracker action runs +4. Downloads manifest artifacts from all builds (multi-arch) +5. **Automatically discovers services** by scanning artifact directories (same logic as publish workflow) +6. Calculates sizes from manifests (config + sum of layers) +7. Compares against `develop` baseline (fetched from registry) +8. Loads historical data from `image-size-history` branch +9. Generates trend chart showing evolution + current PR impact +10. Posts/updates PR comment with detailed report +11. On merge to `develop`, saves measurement to history + +## Advantages + +- **Faster**: No need to pull or inspect images remotely +- **Accurate**: Sizes calculated from exact manifests at build time +- **Reliable**: Works even if registry is slow or rate-limited +- **Efficient**: Uses build artifacts already available in the workflow +- **Complete**: Full manifest data preserved for future analysis +- **Dynamic**: Automatically tracks all built services without configuration +- **Consistent**: Single-platform comparison ensures fair baseline comparison + +### Why Single Platform Comparison? + +Comparing a single platform (arm64 by default) instead of multi-arch total provides: + +1. **Fair Comparison**: Changes in one architecture don't mask issues in another +2. **Consistency**: Same platform compared across PR and baseline +3. **Simplicity**: Easier to understand what changed +4. **Focus**: arm64 is the primary production platform + +If you need to track both architectures, run the action twice with different `platform` values. ## Example Output @@ -65,6 +91,7 @@ Already integrated in `.github/workflows/ci.yml`: repository: ${{ needs.release-versions.outputs.lowercase-repo }} tag: ${{ needs.release-versions.outputs.gh-docker-tag }} baseline-tag: develop + platform: arm64 # Optional: defaults to arm64 ``` ## Inputs @@ -76,16 +103,75 @@ Already integrated in `.github/workflows/ci.yml`: | `repository` | Repository name | Yes | - | | `tag` | Image tag to measure | Yes | - | | `baseline-tag` | Baseline tag to compare | No | `develop` | -| `services` | JSON array of services | No | All services | +| `platform` | Platform architecture (amd64 or arm64) | No | `arm64` | + +**Notes:** +- Services are automatically discovered from build artifacts +- Comparison is done for a **single platform** (not total multi-arch size) +- Default platform is `arm64` for consistency ## Outputs | Output | Description | |--------|-------------| -| `total-size` | Total size in bytes | -| `size-diff` | Size difference in bytes | +| `total-size` | Total size in bytes (for specified platform) | +| `size-diff` | Size difference in bytes (for specified platform) | | `size-diff-percent` | Size difference percentage | +## Data Structure + +The build action saves complete image manifests in artifacts: + +``` +/tmp/digests/ +├── rocketchat/ +│ ├── amd64/ +│ │ └── manifest.json # Complete image manifest +│ └── arm64/ +│ └── manifest.json +├── authorization-service/ +│ ├── amd64/ +│ │ └── manifest.json +│ └── arm64/ +│ └── manifest.json +... +``` + +Each `manifest.json` contains the full verbose manifest from `docker manifest inspect -v`: +```json +{ + "Ref": "ghcr.io/rocketchat/rocket.chat@sha256:...", + "Descriptor": { + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "digest": "sha256:...", + "size": 1234 + }, + "SchemaV2Manifest": { + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": { + "mediaType": "application/vnd.docker.container.image.v1+json", + "size": 12345, + "digest": "sha256:..." + }, + "layers": [ + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 123456, + "digest": "sha256:..." + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 234567, + "digest": "sha256:..." + } + ] + } +} +``` + +Total size is calculated as: `SchemaV2Manifest.config.size + sum(SchemaV2Manifest.layers[].size)` + ## Historical Data The action stores historical measurements in a Git orphan branch called `image-size-history`: @@ -121,20 +207,21 @@ git push origin image-size-history ## Requirements -- `skopeo` (installed automatically) - `jq` (installed automatically) - `bc` (installed automatically) -- Images must be pushed to registry before tracking +- Docker CLI with manifest support +- Images must be built before tracking +- Build artifacts with manifest.json files - Write permissions to repository (for history storage) ## Customization -### Track Specific Services +### Compare Different Platform ```yaml - uses: ./.github/actions/docker-image-size-tracker with: - services: '["rocketchat","authorization-service"]' + platform: 'amd64' ``` ### Use Different Baseline @@ -145,6 +232,22 @@ git push origin image-size-history baseline-tag: 'latest' ``` +### Track Both Platforms + +```yaml +- name: Track arm64 sizes + uses: ./.github/actions/docker-image-size-tracker + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + platform: arm64 + +- name: Track amd64 sizes + uses: ./.github/actions/docker-image-size-tracker + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + platform: amd64 +``` + ## Troubleshooting ### Images not found diff --git a/.github/actions/docker-image-size-tracker/action.yml b/.github/actions/docker-image-size-tracker/action.yml index 9a1b797acd121..52a5aeee48ce9 100644 --- a/.github/actions/docker-image-size-tracker/action.yml +++ b/.github/actions/docker-image-size-tracker/action.yml @@ -20,10 +20,10 @@ inputs: description: 'Baseline tag to compare against' required: false default: 'develop' - services: - description: 'JSON array of service names to track' + platform: + description: 'Platform architecture to compare (amd64 or arm64)' required: false - default: '["rocketchat","authorization-service","account-service","ddp-streamer-service","presence-service","stream-hub-service","queue-worker-service","omnichannel-transcript-service"]' + default: 'arm64' outputs: total-size: @@ -47,70 +47,74 @@ runs: - name: Install dependencies shell: bash run: | - if ! command -v skopeo &> /dev/null; then - sudo apt-get update -qq - sudo apt-get install -y skopeo jq bc - fi + sudo apt-get update -qq + sudo apt-get install -y jq bc + + - name: Login to registry (for baseline) + uses: docker/login-action@v3 + with: + registry: ${{ inputs.registry }} + username: ${{ github.actor }} + password: ${{ inputs.github-token }} + + - name: Download manifests + uses: actions/download-artifact@v6 + with: + pattern: manifests-* + path: /tmp/manifests + merge-multiple: true - - name: Measure image sizes + - name: Measure image sizes from artifacts id: measure shell: bash run: | - set -o xtrace - - SERVICES='${{ inputs.services }}' - REGISTRY="${{ inputs.registry }}" - ORG="${{ inputs.repository }}" - TAG="${{ inputs.tag }}" - - echo "Measuring images: $REGISTRY/$ORG/*:$TAG" + PLATFORM="${{ inputs.platform }}" + echo "Reading image sizes from build artifacts for platform: $PLATFORM" declare -A sizes + declare -a services_list total=0 - for service in $(echo "$SERVICES" | jq -r '.[]'); do - if [[ "$service" == "rocketchat" ]]; then - image_name="rocket.chat" - else - image_name="$service" - fi + # Loop through service directories (same as publish workflow) + shopt -s nullglob + for service_dir in /tmp/manifests/*; do + [[ -d "$service_dir" ]] || continue + service="$(basename "$service_dir")" - image="$REGISTRY/$ORG/$image_name:$TAG" - image_not_tag="$REGISTRY/$ORG/$image_name" - echo "Measuring $image..." + echo "Processing service: $service" + services_list+=("$service") size=0 - if manifest=$(skopeo inspect --raw "docker://$image" 2>/dev/null); then - # Handle multi-arch manifests - if echo "$manifest" | jq -e '.manifests' > /dev/null 2>&1; then - digest=$(echo "$manifest" | jq -r '.manifests[] | select(.platform.architecture=="amd64") | .digest' | head -1) - if [[ -n "$digest" ]]; then - manifest=$(skopeo inspect --raw "docker://$image_not_tag@$digest" 2>/dev/null) - fi - fi - - # Sum layer sizes - size=$(echo "$manifest" | jq '[.layers[]?.size // 0] | add // 0') - config_size=$(echo "$manifest" | jq '.config.size // 0') - size=$((size + config_size)) + # Read only the specified platform architecture + manifest_file="$service_dir/$PLATFORM/manifest.json" + if [[ -f "$manifest_file" ]]; then + # Docker manifest inspect -v returns SchemaV2Manifest with sizes + # Extract config size and layer sizes + config_size=$(jq -r '.SchemaV2Manifest.config.size // 0' "$manifest_file") + layers_size=$(jq '[.SchemaV2Manifest.layers[]?.size // 0] | add // 0' "$manifest_file") + size=$((config_size + layers_size)) + + echo " → Found $manifest_file: $size bytes (config: $config_size, layers: $layers_size)" + else + echo " ⚠ Manifest not found for platform $PLATFORM: $manifest_file" fi - echo " → $service: $size bytes" sizes[$service]=$size total=$((total + size)) done + echo "Total size (all services, $PLATFORM only): $total bytes" echo "total-size=$total" >> $GITHUB_OUTPUT # Save to JSON echo "{" > current-sizes.json echo " \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"," >> current-sizes.json - echo " \"tag\": \"$TAG\"," >> current-sizes.json + echo " \"tag\": \"${{ inputs.tag }}\"," >> current-sizes.json echo " \"total\": $total," >> current-sizes.json echo " \"services\": {" >> current-sizes.json first=true - for service in $(echo "$SERVICES" | jq -r '.[]'); do + for service in "${services_list[@]}"; do if [[ "$first" == "true" ]]; then first=false else @@ -122,48 +126,73 @@ runs: echo " }" >> current-sizes.json echo "}" >> current-sizes.json + echo "Current sizes saved:" + cat current-sizes.json + + # Save services list for baseline measurement + printf '%s\n' "${services_list[@]}" > /tmp/services-list.txt + - name: Measure baseline id: baseline shell: bash continue-on-error: true run: | - SERVICES='${{ inputs.services }}' REGISTRY="${{ inputs.registry }}" ORG="${{ inputs.repository }}" TAG="${{ inputs.baseline-tag }}" + PLATFORM="${{ inputs.platform }}" - echo "Measuring baseline: $REGISTRY/$ORG/*:$TAG" + echo "Measuring baseline: $REGISTRY/$ORG/*:$TAG (platform: $PLATFORM)" declare -A sizes + declare -a services_list total=0 - for service in $(echo "$SERVICES" | jq -r '.[]'); do - if [[ "$service" == "rocketchat" ]]; then + # Read services list from current measurement + while IFS= read -r service; do + services_list+=("$service") + + # Map service name to image name (handle rocketchat -> rocket.chat) + if [[ "$service" == "rocketchat" ]] || [[ "$service" == "rocketchat-cov" ]]; then image_name="rocket.chat" + [[ "$service" == "rocketchat-cov" ]] && image_name="rocket.chat-cov" else image_name="$service" fi image="$REGISTRY/$ORG/$image_name:$TAG" - image_not_tag="$REGISTRY/$ORG/$image_name" + echo " → Inspecting $image" size=0 - if manifest=$(skopeo inspect --raw "docker://$image" 2>/dev/null); then + if manifest=$(docker manifest inspect "$image" 2>/dev/null); then + # Check if it's a manifest list (multi-arch) if echo "$manifest" | jq -e '.manifests' > /dev/null 2>&1; then - digest=$(echo "$manifest" | jq -r '.manifests[] | select(.platform.architecture=="amd64") | .digest' | head -1) - if [[ -n "$digest" ]]; then - manifest=$(skopeo inspect --raw "docker://$image_not_tag@$digest" 2>/dev/null) + # Manifest list - find the specified platform + echo " → Multi-arch manifest detected, filtering for $PLATFORM" + platform_digest=$(echo "$manifest" | jq -r --arg arch "$PLATFORM" '.manifests[] | select(.platform.architecture == $arch) | .digest' | head -1) + + if [[ -n "$platform_digest" ]]; then + echo " → Inspecting $PLATFORM platform: $platform_digest" + if platform_manifest=$(docker manifest inspect "$REGISTRY/$ORG/$image_name@$platform_digest" 2>/dev/null); then + config_size=$(echo "$platform_manifest" | jq -r '.config.size // 0') + layers_size=$(echo "$platform_manifest" | jq '[.layers[]?.size // 0] | add // 0') + size=$((config_size + layers_size)) + echo " → Size: $size bytes" + fi + else + echo " ⚠ Platform $PLATFORM not found in manifest" fi + else + # Single arch manifest + config_size=$(echo "$manifest" | jq -r '.config.size // 0') + layers_size=$(echo "$manifest" | jq '[.layers[]?.size // 0] | add // 0') + size=$((config_size + layers_size)) fi - - size=$(echo "$manifest" | jq '[.layers[]?.size // 0] | add // 0') - config_size=$(echo "$manifest" | jq '.config.size // 0') - size=$((size + config_size)) fi sizes[$service]=$size total=$((total + size)) - done + done < /tmp/services-list.txt echo "baseline-total=$total" >> $GITHUB_OUTPUT @@ -174,7 +203,7 @@ runs: echo " \"services\": {" >> baseline-sizes.json first=true - for service in $(echo "$SERVICES" | jq -r '.[]'); do + for service in "${services_list[@]}"; do if [[ "$first" == "true" ]]; then first=false else @@ -304,10 +333,10 @@ runs: |---------|---------|----------|--------| EOF - SERVICES='${{ inputs.services }}' - for service in $(echo "$SERVICES" | jq -r '.[]'); do + # Get services dynamically from current-sizes.json + for service in $(jq -r '.services | keys[]' current-sizes.json); do current=$(jq -r ".services.\"$service\"" current-sizes.json) - baseline=$(jq -r ".services.\"$service\"" baseline-sizes.json) + baseline=$(jq -r ".services.\"$service\" // 0" baseline-sizes.json) service_diff=$((current - baseline)) if [[ $service_diff -gt 0 ]]; then @@ -429,8 +458,8 @@ runs: issue_number: context.issue.number }); - const botComment = comments.data.find(comment => - comment.user.type === 'Bot' && + const botComment = comments.data.find(comment => + comment.user.type === 'Bot' && comment.body.includes('đŸ“Ļ Docker Image Size Report') ); @@ -454,6 +483,11 @@ runs: console.log('Created new comment'); } + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + branding: icon: 'package' color: 'blue' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45de80cad490f..cb07b17ed5600 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -393,12 +393,12 @@ jobs: username: ${{ secrets.CR_USER }} password: ${{ secrets.CR_PAT }} - - name: Download digests + - name: Download manifests if: github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') uses: actions/download-artifact@v6 with: - pattern: digests-* - path: /tmp/digests + pattern: manifests-* + path: /tmp/manifests merge-multiple: true - name: Create and push multi-arch manifests @@ -407,14 +407,15 @@ jobs: set -o xtrace shopt -s nullglob - for service_dir in /tmp/digests/*; do + for service_dir in /tmp/manifests/*; do [[ -d "$service_dir" ]] || continue service="$(basename "$service_dir")" echo "Creating manifest for $service" + # Extract digests from manifest.json files mapfile -t refs < <( - find "$service_dir" -type f -name 'digest.txt' -print0 \ - | xargs -0 -I{} sh -c "tr -d '\r' < '{}' | sed '/^[[:space:]]*$/d'" + find "$service_dir" -type f -name 'manifest.json' -print0 \ + | xargs -0 -I{} jq -r '.Descriptor.digest as $digest | .Ref | split("@")[0] + "@" + $digest' {} ) echo "Digest for ${service}: ${refs[@]}" @@ -780,12 +781,12 @@ jobs: username: ${{ secrets.CR_USER }} password: ${{ secrets.CR_PAT }} - - name: Download digests + - name: Download manifests if: github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') uses: actions/download-artifact@v6 with: - pattern: digests-* - path: /tmp/digests + pattern: manifests-* + path: /tmp/manifests merge-multiple: true - name: Publish Docker images @@ -829,7 +830,7 @@ jobs: # get first tag as primary PRIMARY="${TAGS[0]}" - for service_dir in /tmp/digests/*; do + for service_dir in /tmp/manifests/*; do [[ -d "$service_dir" ]] || continue service="$(basename "$service_dir")" From 86035f699a61cb74ecd779d6b51a866c4544b99e Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Sat, 15 Nov 2025 16:24:58 -0300 Subject: [PATCH 4/7] Test history --- .github/actions/docker-image-size-tracker/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/docker-image-size-tracker/action.yml b/.github/actions/docker-image-size-tracker/action.yml index 52a5aeee48ce9..65c360b9099ae 100644 --- a/.github/actions/docker-image-size-tracker/action.yml +++ b/.github/actions/docker-image-size-tracker/action.yml @@ -253,7 +253,7 @@ runs: echo "Loaded $count historical measurements" - name: Save current measurement to history - if: github.ref == 'refs/heads/develop' + # if: github.ref == 'refs/heads/develop' shell: bash run: | timestamp=$(date -u +%Y%m%d-%H%M%S) From 2ec7e92ee6e51093e9eab2352680850277e9460e Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Sat, 15 Nov 2025 16:37:22 -0300 Subject: [PATCH 5/7] Merge tables --- .../docker-image-size-tracker/action.yml | 170 +++++++++++------- 1 file changed, 104 insertions(+), 66 deletions(-) diff --git a/.github/actions/docker-image-size-tracker/action.yml b/.github/actions/docker-image-size-tracker/action.yml index 65c360b9099ae..dd5f32908a342 100644 --- a/.github/actions/docker-image-size-tracker/action.yml +++ b/.github/actions/docker-image-size-tracker/action.yml @@ -39,23 +39,12 @@ outputs: runs: using: 'composite' steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Install dependencies - shell: bash - run: | - sudo apt-get update -qq - sudo apt-get install -y jq bc - - - name: Login to registry (for baseline) - uses: docker/login-action@v3 - with: - registry: ${{ inputs.registry }} - username: ${{ github.actor }} - password: ${{ inputs.github-token }} + # - name: Login to registry (for baseline) + # uses: docker/login-action@v3 + # with: + # registry: ${{ inputs.registry }} + # username: ${{ github.actor }} + # password: ${{ inputs.github-token }} - name: Download manifests uses: actions/download-artifact@v6 @@ -240,17 +229,21 @@ runs: mkdir -p history - name: Load historical data - id: load-history shell: bash run: | - # Load last 30 measurements + # Load last 30 measurements and group by day (keep only last entry per day) echo "[]" > history-data.json if [[ -d history ]]; then - jq -s '.' history/*.json 2>/dev/null | jq 'sort_by(.timestamp) | .[-30:]' > history-data.json || echo "[]" > history-data.json + jq -s '.' history/*.json 2>/dev/null | jq ' + sort_by(.timestamp) | + group_by(.timestamp[0:16]) | + map(.[-1]) | + .[-30:] + ' > history-data.json || echo "[]" > history-data.json fi count=$(jq 'length' history-data.json) - echo "Loaded $count historical measurements" + echo "Loaded $count historical measurements (one per day)" - name: Save current measurement to history # if: github.ref == 'refs/heads/develop' @@ -301,102 +294,149 @@ runs: echo "size-diff=$diff" >> $GITHUB_OUTPUT echo "size-diff-percent=$percent" >> $GITHUB_OUTPUT + color="gray" + if (( $(awk "BEGIN {print ($percent > 1)}") )); then + color="red" + elif (( $(awk "BEGIN {print ($percent < -1)}") )); then + color="green" + fi + # Generate report - if (( $(echo "$diff > 0" | bc -l) )); then + if [[ $diff -gt 0 ]]; then emoji="📈" - badge="![](https://img.shields.io/badge/size-+${percent}%25-red)" + badge="![](https://img.shields.io/badge/size-+${percent}%25-${color})" sign="+" - elif (( $(echo "$diff < 0" | bc -l) )); then + elif [[ $diff -lt 0 ]]; then emoji="📉" - badge="![](https://img.shields.io/badge/size-${percent}%25-green)" - sign="" + badge="![](https://img.shields.io/badge/size--${percent}%25-${color})" + sign="-" else emoji="âžĄī¸" - badge="![](https://img.shields.io/badge/size-unchanged-gray)" + badge="![](https://img.shields.io/badge/size-unchanged-${color})" sign="" fi cat > report.md << EOF # đŸ“Ļ Docker Image Size Report - ## $emoji Summary $badge - - | Metric | Value | - |--------|-------| - | **Current Total** | $(numfmt --to=iec-i --suffix=B $current_total) | - | **Baseline Total** | $(numfmt --to=iec-i --suffix=B $baseline_total) | - | **Difference** | **${sign}$(numfmt --to=iec-i --suffix=B ${diff#-})** (${sign}${percent}%) | - - ## 📊 Service Details + ## $emoji Changes $badge - | Service | Current | Baseline | Change | - |---------|---------|----------|--------| + | Service | Current | Baseline | Change | Percent | + |---------|---------|----------|--------|---------| + | **sum of all images** | **$(numfmt --to=iec-i --suffix=B $current_total)** | **$(numfmt --to=iec-i --suffix=B $baseline_total)** | **${sign}$(numfmt --to=iec-i --suffix=B ${diff#-})** | $badge | EOF - # Get services dynamically from current-sizes.json - for service in $(jq -r '.services | keys[]' current-sizes.json); do + # Get services dynamically from current-sizes.json, sorted by size (largest first) + for service in $(jq -r '.services | to_entries | sort_by(-.value) | .[].key' current-sizes.json); do current=$(jq -r ".services.\"$service\"" current-sizes.json) baseline=$(jq -r ".services.\"$service\" // 0" baseline-sizes.json) service_diff=$((current - baseline)) + if [[ $baseline -gt 0 ]]; then + service_percent=$(awk "BEGIN {printf \"%.2f\", ($service_diff / $baseline) * 100}") + else + service_percent=0 + fi + + color="gray" + if (( $(awk "BEGIN {print ($service_percent > 1)}") )); then + color="red" + elif (( $(awk "BEGIN {print ($service_percent < -1)}") )); then + color="green" + fi + if [[ $service_diff -gt 0 ]]; then - icon="📈" + badge="![](https://img.shields.io/badge/size-+${service_percent}%25-${color})" sign="+" elif [[ $service_diff -lt 0 ]]; then - icon="📉" - sign="" + badge="![](https://img.shields.io/badge/size--${service_percent}%25-${color})" + sign="-" else - icon="âžĄī¸" + badge="![](https://img.shields.io/badge/size-unchanged-${color})" sign="" fi - echo "| $icon **$service** | $(numfmt --to=iec-i --suffix=B $current) | $(numfmt --to=iec-i --suffix=B $baseline) | ${sign}$(numfmt --to=iec-i --suffix=B ${service_diff#-}) |" >> report.md + echo "| $service | $(numfmt --to=iec-i --suffix=B $current) | $(numfmt --to=iec-i --suffix=B $baseline) | ${sign}$(numfmt --to=iec-i --suffix=B ${service_diff#-}) | $badge |" >> report.md done # Generate historical trend chart echo "" >> report.md - echo "## 📈 Historical Trend" >> report.md + echo "## 📊 Historical Trend" >> report.md echo "" >> report.md # Load history and generate mermaid chart history_count=$(jq 'length' history-data.json) if [[ $history_count -gt 0 ]]; then + # Get all services from current build + all_services=$(jq -r '.services | keys | .[]' current-sizes.json | sort) + # Generate Mermaid chart data x_labels="" - y_values="" + declare -A service_data + + # Initialize service data arrays + for service in $all_services; do + service_data[$service]="" + done + # Process historical data while IFS= read -r line; do timestamp=$(echo "$line" | jq -r '.timestamp') - total=$(echo "$line" | jq -r '.total') - - date_label=$(echo "$timestamp" | cut -d'T' -f1 | cut -d'-' -f2- | tr '-' '/') - size_gb=$(awk "BEGIN {printf \"%.2f\", $total / 1073741824}") + date_label=$(date -d "$timestamp" +"%m/%d %H:%M") if [[ -z "$x_labels" ]]; then x_labels="\"$date_label\"" - y_values="$size_gb" else x_labels="$x_labels, \"$date_label\"" - y_values="$y_values, $size_gb" fi + + # Add data point for each service + for service in $all_services; do + size=$(echo "$line" | jq -r ".services.\"$service\" // 0") + size_gb=$(awk "BEGIN {printf \"%.2f\", $size / 1073741824}") + + if [[ -z "${service_data[$service]}" ]]; then + service_data[$service]="$size_gb" + else + service_data[$service]="${service_data[$service]}, $size_gb" + fi + done done < <(jq -c '.[]' history-data.json) # Add current PR as last point - current_date=$(date -u +"%m/%d") - current_gb=$(awk "BEGIN {printf \"%.2f\", $current_total / 1073741824}") + current_date=$(date -u +"%m/%d %H:%M") x_labels="$x_labels, \"$current_date (PR)\"" - y_values="$y_values, $current_gb" + for service in $all_services; do + size=$(jq -r ".services.\"$service\" // 0" current-sizes.json) + size_gb=$(awk "BEGIN {printf \"%.2f\", $size / 1073741824}") + service_data[$service]="${service_data[$service]}, $size_gb" + done + + # Generate mermaid chart with multiple lines cat >> report.md << EOF \`\`\`mermaid - %%{init: {'theme':'base'}}%% - xychart-beta - title "Total Image Size Evolution (Last 30 Builds + This PR)" + --- + config: + theme: "dark" + xyChart: + width: 900 + height: 400 + --- + xychart + title "Image Size Evolution by Service (Last 30 Days + This PR)" x-axis [$x_labels] - y-axis "Size (GB)" 0 --> 20 - line [$y_values] - \`\`\` + y-axis "Size (GB)" 0 --> 3 + EOF + + # Add a line for each service + for service in $all_services; do + echo " line \"$service\" [${service_data[$service]}]" >> report.md + done + + cat >> report.md << 'EOF' + ``` EOF @@ -406,7 +446,7 @@ runs: avg_size=$(jq '[.[].total] | add / length' history-data.json) cat >> report.md << EOF - **Statistics (last $history_count builds):** + **Statistics (last $history_count days):** - 📊 Average: $(numfmt --to=iec-i --suffix=B ${avg_size%.*}) - âŦ‡ī¸ Minimum: $(numfmt --to=iec-i --suffix=B $min_size) - âŦ†ī¸ Maximum: $(numfmt --to=iec-i --suffix=B $max_size) @@ -484,9 +524,7 @@ runs: } - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 + uses: actions/checkout@v5 branding: icon: 'package' From b76747447ba68b19d2b1903564085e2a1de57a30 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Sat, 15 Nov 2025 19:01:00 -0300 Subject: [PATCH 6/7] Optimize branch changes --- .../docker-image-size-tracker/action.yml | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/.github/actions/docker-image-size-tracker/action.yml b/.github/actions/docker-image-size-tracker/action.yml index dd5f32908a342..50b119035151b 100644 --- a/.github/actions/docker-image-size-tracker/action.yml +++ b/.github/actions/docker-image-size-tracker/action.yml @@ -211,12 +211,17 @@ runs: git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" + # Create a separate worktree for history branch + mkdir -p /tmp/history-worktree + # Try to fetch history branch if git ls-remote --heads origin image-size-history | grep -q image-size-history; then - git fetch origin image-size-history:image-size-history - git checkout image-size-history + git fetch origin image-size-history + git worktree add /tmp/history-worktree image-size-history else - # Create orphan branch for history + # Create orphan branch for history in worktree + git worktree add --detach /tmp/history-worktree + cd /tmp/history-worktree git checkout --orphan image-size-history git rm -rf . 2>/dev/null || true mkdir -p history @@ -224,17 +229,19 @@ runs: echo "This branch stores historical image size data for tracking" >> README.md git add README.md git commit -m "Initialize image size history" + git push origin image-size-history + cd - fi - mkdir -p history + mkdir -p /tmp/history-worktree/history - name: Load historical data shell: bash run: | # Load last 30 measurements and group by day (keep only last entry per day) echo "[]" > history-data.json - if [[ -d history ]]; then - jq -s '.' history/*.json 2>/dev/null | jq ' + if [[ -d /tmp/history-worktree/history ]]; then + jq -s '.' /tmp/history-worktree/history/*.json 2>/dev/null | jq ' sort_by(.timestamp) | group_by(.timestamp[0:16]) | map(.[-1]) | @@ -253,11 +260,13 @@ runs: commit_sha="${{ github.sha }}" # Add commit info to current measurement - jq --arg sha "$commit_sha" '. + {commit: $sha}' current-sizes.json > "history/${timestamp}.json" + jq --arg sha "$commit_sha" '. + {commit: $sha}' current-sizes.json > "/tmp/history-worktree/history/${timestamp}.json" + cd /tmp/history-worktree git add "history/${timestamp}.json" git commit -m "Add measurement for ${timestamp} (${commit_sha:0:7})" git push origin image-size-history + cd - echo "Saved measurement to history" @@ -295,9 +304,9 @@ runs: echo "size-diff-percent=$percent" >> $GITHUB_OUTPUT color="gray" - if (( $(awk "BEGIN {print ($percent > 1)}") )); then + if (( $(awk "BEGIN {print ($percent > 0.01)}") )); then color="red" - elif (( $(awk "BEGIN {print ($percent < -1)}") )); then + elif (( $(awk "BEGIN {print ($percent < -0.01)}") )); then color="green" fi @@ -339,9 +348,9 @@ runs: fi color="gray" - if (( $(awk "BEGIN {print ($service_percent > 1)}") )); then + if (( $(awk "BEGIN {print ($service_percent > 0.01)}") )); then color="red" - elif (( $(awk "BEGIN {print ($service_percent < -1)}") )); then + elif (( $(awk "BEGIN {print ($service_percent < -0.01)}") )); then color="green" fi @@ -427,7 +436,7 @@ runs: xychart title "Image Size Evolution by Service (Last 30 Days + This PR)" x-axis [$x_labels] - y-axis "Size (GB)" 0 --> 3 + y-axis "Size (GB)" 0 --> 0.5 EOF # Add a line for each service @@ -523,8 +532,13 @@ runs: console.log('Created new comment'); } - - name: Checkout repository - uses: actions/checkout@v5 + - name: Cleanup worktree + if: always() + shell: bash + run: | + if [[ -d /tmp/history-worktree ]]; then + git worktree remove /tmp/history-worktree --force 2>/dev/null || true + fi branding: icon: 'package' From c6be953a496299e8ad278dda23d94ce76adcd578 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Sun, 16 Nov 2025 11:04:01 -0300 Subject: [PATCH 7/7] Clean up --- .../docker-image-size-tracker/README.md | 278 ------------------ .../docker-image-size-tracker/action.yml | 9 +- .github/workflows/ci.yml | 2 +- 3 files changed, 2 insertions(+), 287 deletions(-) delete mode 100644 .github/actions/docker-image-size-tracker/README.md diff --git a/.github/actions/docker-image-size-tracker/README.md b/.github/actions/docker-image-size-tracker/README.md deleted file mode 100644 index 510387d3fc9f2..0000000000000 --- a/.github/actions/docker-image-size-tracker/README.md +++ /dev/null @@ -1,278 +0,0 @@ -# Docker Image Size Tracker - -Automatically tracks and reports Docker image sizes in Pull Requests with historical trend analysis. - -## Features - -- 📊 **Automatic Tracking**: Captures image sizes during build (no remote inspection) -- 📈 **PR Comments**: Posts size comparison vs `develop` baseline -- 📉 **Historical Trends**: Tracks size evolution over time with visual charts -- đŸŽ¯ **Multi-Service**: Tracks all microservices independently -- 🔔 **Visual Reports**: Tables, graphs, and statistics -- 💾 **Persistent Storage**: Stores history in Git orphan branch -- ⚡ **Fast**: Reads sizes from build artifacts instead of remote registry - -## How It Works - -1. During Docker build, image manifest is inspected and saved to artifacts -2. Manifest contains digest, config size, and all layer sizes -3. After images are published in CI, the tracker action runs -4. Downloads manifest artifacts from all builds (multi-arch) -5. **Automatically discovers services** by scanning artifact directories (same logic as publish workflow) -6. Calculates sizes from manifests (config + sum of layers) -7. Compares against `develop` baseline (fetched from registry) -8. Loads historical data from `image-size-history` branch -9. Generates trend chart showing evolution + current PR impact -10. Posts/updates PR comment with detailed report -11. On merge to `develop`, saves measurement to history - -## Advantages - -- **Faster**: No need to pull or inspect images remotely -- **Accurate**: Sizes calculated from exact manifests at build time -- **Reliable**: Works even if registry is slow or rate-limited -- **Efficient**: Uses build artifacts already available in the workflow -- **Complete**: Full manifest data preserved for future analysis -- **Dynamic**: Automatically tracks all built services without configuration -- **Consistent**: Single-platform comparison ensures fair baseline comparison - -### Why Single Platform Comparison? - -Comparing a single platform (arm64 by default) instead of multi-arch total provides: - -1. **Fair Comparison**: Changes in one architecture don't mask issues in another -2. **Consistency**: Same platform compared across PR and baseline -3. **Simplicity**: Easier to understand what changed -4. **Focus**: arm64 is the primary production platform - -If you need to track both architectures, run the action twice with different `platform` values. - -## Example Output - -```markdown -đŸ“Ļ Docker Image Size Report - -📈 Summary 🔴 +2.3% - -| Metric | Value | -|--------|-------| -| Current Total | 1.23 GB | -| Baseline Total | 1.20 GB | -| Difference | +27.65 MB (+2.30%) | - -📊 Service Details - -| Service | Current | Baseline | Change | -|---------|---------|----------|--------| -| 📈 rocketchat | 850 MB | 830 MB | +20 MB | -| 📉 auth-service | 120 MB | 125 MB | -5 MB | - -📈 Historical Trend - -[Mermaid chart showing last 30 builds + current PR] - -Statistics (last 30 builds): -- 📊 Average: 1.19 GB -- âŦ‡ī¸ Minimum: 1.15 GB -- âŦ†ī¸ Maximum: 1.25 GB -- đŸŽ¯ Current PR: 1.23 GB -``` - -## Usage - -Already integrated in `.github/workflows/ci.yml`: - -```yaml -- name: Track Docker image sizes - uses: ./.github/actions/docker-image-size-tracker - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - registry: ghcr.io - repository: ${{ needs.release-versions.outputs.lowercase-repo }} - tag: ${{ needs.release-versions.outputs.gh-docker-tag }} - baseline-tag: develop - platform: arm64 # Optional: defaults to arm64 -``` - -## Inputs - -| Input | Description | Required | Default | -|-------|-------------|----------|---------| -| `github-token` | Token for PR comments | Yes | - | -| `registry` | Container registry | No | `ghcr.io` | -| `repository` | Repository name | Yes | - | -| `tag` | Image tag to measure | Yes | - | -| `baseline-tag` | Baseline tag to compare | No | `develop` | -| `platform` | Platform architecture (amd64 or arm64) | No | `arm64` | - -**Notes:** -- Services are automatically discovered from build artifacts -- Comparison is done for a **single platform** (not total multi-arch size) -- Default platform is `arm64` for consistency - -## Outputs - -| Output | Description | -|--------|-------------| -| `total-size` | Total size in bytes (for specified platform) | -| `size-diff` | Size difference in bytes (for specified platform) | -| `size-diff-percent` | Size difference percentage | - -## Data Structure - -The build action saves complete image manifests in artifacts: - -``` -/tmp/digests/ -├── rocketchat/ -│ ├── amd64/ -│ │ └── manifest.json # Complete image manifest -│ └── arm64/ -│ └── manifest.json -├── authorization-service/ -│ ├── amd64/ -│ │ └── manifest.json -│ └── arm64/ -│ └── manifest.json -... -``` - -Each `manifest.json` contains the full verbose manifest from `docker manifest inspect -v`: -```json -{ - "Ref": "ghcr.io/rocketchat/rocket.chat@sha256:...", - "Descriptor": { - "mediaType": "application/vnd.docker.distribution.manifest.v2+json", - "digest": "sha256:...", - "size": 1234 - }, - "SchemaV2Manifest": { - "schemaVersion": 2, - "mediaType": "application/vnd.docker.distribution.manifest.v2+json", - "config": { - "mediaType": "application/vnd.docker.container.image.v1+json", - "size": 12345, - "digest": "sha256:..." - }, - "layers": [ - { - "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", - "size": 123456, - "digest": "sha256:..." - }, - { - "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", - "size": 234567, - "digest": "sha256:..." - } - ] - } -} -``` - -Total size is calculated as: `SchemaV2Manifest.config.size + sum(SchemaV2Manifest.layers[].size)` - -## Historical Data - -The action stores historical measurements in a Git orphan branch called `image-size-history`: - -- **Automatic Storage**: When changes are merged to `develop`, measurements are saved -- **Retention**: Last 30 measurements are used for trend analysis -- **Format**: JSON files with timestamps and commit references -- **Branch Structure**: - ``` - image-size-history/ - ├── README.md - └── history/ - ├── 20241115-143022.json - ├── 20241115-150145.json - └── ... - ``` - -### Manual History Management - -View history branch: -```bash -git fetch origin image-size-history -git checkout image-size-history -``` - -Clean old measurements: -```bash -git checkout image-size-history -rm history/old-*.json -git commit -m "Clean old measurements" -git push origin image-size-history -``` - -## Requirements - -- `jq` (installed automatically) -- `bc` (installed automatically) -- Docker CLI with manifest support -- Images must be built before tracking -- Build artifacts with manifest.json files -- Write permissions to repository (for history storage) - -## Customization - -### Compare Different Platform - -```yaml -- uses: ./.github/actions/docker-image-size-tracker - with: - platform: 'amd64' -``` - -### Use Different Baseline - -```yaml -- uses: ./.github/actions/docker-image-size-tracker - with: - baseline-tag: 'latest' -``` - -### Track Both Platforms - -```yaml -- name: Track arm64 sizes - uses: ./.github/actions/docker-image-size-tracker - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - platform: arm64 - -- name: Track amd64 sizes - uses: ./.github/actions/docker-image-size-tracker - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - platform: amd64 -``` - -## Troubleshooting - -### Images not found - -Ensure the job runs after images are published: - -```yaml -needs: [build-gh-docker-publish, release-versions] -``` - -### History not showing - -- History starts accumulating after first merge to `develop` -- Requires `contents: write` permission in workflow -- Check if `image-size-history` branch exists - -### No PR comment appears - -Check that the workflow has permissions: - -```yaml -permissions: - pull-requests: write -``` - -## License - -Same as Rocket.Chat project. diff --git a/.github/actions/docker-image-size-tracker/action.yml b/.github/actions/docker-image-size-tracker/action.yml index 50b119035151b..6d82c66a77675 100644 --- a/.github/actions/docker-image-size-tracker/action.yml +++ b/.github/actions/docker-image-size-tracker/action.yml @@ -39,13 +39,6 @@ outputs: runs: using: 'composite' steps: - # - name: Login to registry (for baseline) - # uses: docker/login-action@v3 - # with: - # registry: ${{ inputs.registry }} - # username: ${{ github.actor }} - # password: ${{ inputs.github-token }} - - name: Download manifests uses: actions/download-artifact@v6 with: @@ -253,7 +246,7 @@ runs: echo "Loaded $count historical measurements (one per day)" - name: Save current measurement to history - # if: github.ref == 'refs/heads/develop' + if: github.ref == 'refs/heads/develop' shell: bash run: | timestamp=$(date -u +%Y%m%d-%H%M%S) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb07b17ed5600..7769dab4a80d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -440,7 +440,7 @@ jobs: track-image-sizes: name: đŸ“Ļ Track Image Sizes needs: [build-gh-docker-publish, release-versions] - runs-on: ubuntu-24.04 + runs-on: ubuntu-24.04-arm if: github.event_name == 'pull_request' || github.ref == 'refs/heads/develop' permissions: pull-requests: write