From 3306c7ac455a40a8625379019c4d0bb2f9934e85 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Sun, 14 Jun 2026 23:59:47 +0300 Subject: [PATCH 1/3] ci: report image size changes on image PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On pull requests, each image build matrix now measures the new image's size and compares it against the currently published rolling tag for the same runtime version (e.g. apify/actor-node:22), uploading a per-image report. A new size-report job aggregates those reports and posts/updates a single sticky comment per workflow with a current/new/Δ table. The Markdown is rendered by a TypeScript script run natively on Node.js 24 (type stripping, no build step). Applies to all six image workflows (node, node-playwright, node-puppeteer, python, python-playwright, python-selenium). --- .github/scripts/format-image-size-report.ts | 118 ++++++++++++++++++ .../workflows/release-node-playwright.yaml | 73 +++++++++++ .github/workflows/release-node-puppeteer.yaml | 73 +++++++++++ .github/workflows/release-node.yaml | 73 +++++++++++ .../workflows/release-python-playwright.yaml | 73 +++++++++++ .../workflows/release-python-selenium.yaml | 73 +++++++++++ .github/workflows/release-python.yaml | 73 +++++++++++ 7 files changed, 556 insertions(+) create mode 100644 .github/scripts/format-image-size-report.ts diff --git a/.github/scripts/format-image-size-report.ts b/.github/scripts/format-image-size-report.ts new file mode 100644 index 00000000..fd199a1e --- /dev/null +++ b/.github/scripts/format-image-size-report.ts @@ -0,0 +1,118 @@ +// Renders the Markdown body for the "image size" PR comment. +// +// Usage: node format-image-size-report.ts +// Runs natively on Node.js >=24 via built-in TypeScript type stripping (no build step). +// +// contains one JSON file per built image (uploaded as artifacts by +// the build matrix). Each file has the shape described by SizeReport below; +// `currentBytes` is empty when there is no published baseline image to compare against. + +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +interface SizeReport { + matrix?: Record; + baseImage?: string; + currentBytes?: string; + newBytes?: string; +} + +// Matrix keys we never want to surface as a "variant" in the table. +const IGNORED_VERSION_KEYS = new Set([ + 'node-version', + 'python-version', + 'apify-version', + 'crawlee-version', + 'latest-node-version', + 'latest-python-version', +]); + +const MiB = 1024 * 1024; + +function readReports(reportsDir: string): SizeReport[] { + let files: string[]; + try { + files = readdirSync(reportsDir).filter((file) => file.endsWith('.json')); + } catch { + return []; + } + + const reports: SizeReport[] = []; + for (const file of files) { + try { + reports.push(JSON.parse(readFileSync(join(reportsDir, file), 'utf8')) as SizeReport); + } catch { + // Skip unreadable/corrupt report files rather than failing the whole comment. + } + } + return reports; +} + +function formatSize(bytes: string | undefined): string | null { + const n = Number(bytes); + if (!bytes || !Number.isFinite(n)) return null; + return `${(n / MiB).toFixed(1)} MiB`; +} + +function formatDelta(currentBytes: string | undefined, newBytes: string | undefined): string { + const current = Number(currentBytes); + const next = Number(newBytes); + if (!currentBytes || !Number.isFinite(current) || !Number.isFinite(next)) { + return '🆕 _new image_'; + } + const diff = next - current; + const pct = current === 0 ? 0 : (diff / current) * 100; + const sign = diff > 0 ? '+' : diff < 0 ? '−' : ''; + const emoji = diff > 0 ? '🔺' : diff < 0 ? '🔻' : '➖'; + const absMiB = (Math.abs(diff) / MiB).toFixed(1); + return `${emoji} ${sign}${absMiB} MiB (${sign}${Math.abs(pct).toFixed(1)}%)`; +} + +function describeVariant(matrix: Record = {}): string { + const variants: string[] = []; + for (const [key, value] of Object.entries(matrix)) { + if (!value || IGNORED_VERSION_KEYS.has(key) || !key.endsWith('-version')) continue; + variants.push(`${key.replace(/-version$/, '')} ${value}`); + } + return variants.join(', '); +} + +function render(reports: SizeReport[]): string { + const lines: string[] = []; + lines.push('### 📦 Image size report'); + lines.push(''); + + if (reports.length === 0) { + lines.push('No image size data was collected (no images were built in this run).'); + lines.push(''); + return lines.join('\n'); + } + + lines.push( + 'Built images compared against the currently published rolling tag for the same runtime version ' + + '(e.g. `apify/actor-node:22`). Sizes are the **uncompressed** on-disk size reported by ' + + '`docker image inspect`, so they will be larger than the compressed download size shown on Docker Hub.', + ); + lines.push(''); + lines.push('| Image | Variant | Current | New | Δ |'); + lines.push('| --- | --- | ---: | ---: | --- |'); + + const rows = reports + .map((report) => ({ + image: report.baseImage || '(unknown)', + variant: describeVariant(report.matrix), + current: formatSize(report.currentBytes) ?? '_n/a_', + next: formatSize(report.newBytes) ?? '_n/a_', + delta: formatDelta(report.currentBytes, report.newBytes), + })) + .sort((a, b) => a.image.localeCompare(b.image) || a.variant.localeCompare(b.variant)); + + for (const row of rows) { + lines.push(`| \`${row.image}\` | ${row.variant || '—'} | ${row.current} | ${row.next} | ${row.delta} |`); + } + + lines.push(''); + return lines.join('\n'); +} + +process.stdout.write(render(readReports(process.argv[2] ?? ''))); diff --git a/.github/workflows/release-node-playwright.yaml b/.github/workflows/release-node-playwright.yaml index 6c17eb19..6a453eb9 100644 --- a/.github/workflows/release-node-playwright.yaml +++ b/.github/workflows/release-node-playwright.yaml @@ -278,6 +278,41 @@ jobs: - name: Test slim image run: docker run ${{ fromJson(steps.prepare-slim-tags.outputs.result).firstImageName }} + - name: Measure image size + id: image-size + if: github.event_name == 'pull_request' + env: + MATRIX_JSON: ${{ toJSON(matrix) }} + NEW_IMAGE: ${{ fromJson(steps.prepare-tags.outputs.result).firstImageName }} + BASE_IMAGE: apify/actor-${{ matrix.image-name }}:${{ matrix.node-version }} + run: | + set -euo pipefail + mkdir -p size-report + id="$(printf '%s' "$MATRIX_JSON" | sha256sum | cut -c1-16)" + new_bytes="$(docker image inspect "$NEW_IMAGE" --format '{{.Size}}')" + if docker pull --platform linux/amd64 "$BASE_IMAGE" >/dev/null 2>&1; then + current_bytes="$(docker image inspect "$BASE_IMAGE" --format '{{.Size}}')" + else + current_bytes="" + echo "No published baseline image found for ${BASE_IMAGE}" + fi + jq -n \ + --argjson matrix "$MATRIX_JSON" \ + --arg base "$BASE_IMAGE" \ + --arg current "$current_bytes" \ + --arg new "$new_bytes" \ + '{matrix: $matrix, baseImage: $base, currentBytes: $current, newBytes: $new}' \ + > "size-report/${id}.json" + echo "id=${id}" >> "$GITHUB_OUTPUT" + + - name: Upload image size report + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: image-size-${{ steps.image-size.outputs.id }} + path: size-report/ + retention-days: 1 + - name: Login to DockerHub if: github.event_name != 'pull_request' uses: docker/login-action@v4 @@ -319,3 +354,41 @@ jobs: cache-from: | type=gha,scope=${{ matrix.image-name }}-${{ matrix.node-version }}-${{ matrix.playwright-version }}-slim type=gha,scope=${{ matrix.image-name }}-${{ matrix.node-version }}-${{ matrix.playwright-version }} + + # Aggregate the per-image size reports uploaded by the build matrix and post/update + # a single sticky PR comment comparing current vs new image sizes. + size-report: + name: Report image size changes + needs: [build-main] + if: ${{ always() && github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download image size reports + uses: actions/download-artifact@v8 + continue-on-error: true + with: + pattern: image-size-* + path: size-reports + merge-multiple: true + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Render comment body + run: | + mkdir -p size-reports + node .github/scripts/format-image-size-report.ts size-reports | tee image-size-comment.md + + - name: Post or update image size comment + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: image-sizes-node-playwright + path: image-size-comment.md diff --git a/.github/workflows/release-node-puppeteer.yaml b/.github/workflows/release-node-puppeteer.yaml index 695527f9..824d8f6e 100644 --- a/.github/workflows/release-node-puppeteer.yaml +++ b/.github/workflows/release-node-puppeteer.yaml @@ -258,6 +258,41 @@ jobs: - name: Test slim image run: docker run ${{ fromJson(steps.prepare-slim-tags.outputs.result).firstImageName }} + - name: Measure image size + id: image-size + if: github.event_name == 'pull_request' + env: + MATRIX_JSON: ${{ toJSON(matrix) }} + NEW_IMAGE: ${{ fromJson(steps.prepare-tags.outputs.result).firstImageName }} + BASE_IMAGE: apify/actor-${{ matrix.image-name }}:${{ matrix.node-version }} + run: | + set -euo pipefail + mkdir -p size-report + id="$(printf '%s' "$MATRIX_JSON" | sha256sum | cut -c1-16)" + new_bytes="$(docker image inspect "$NEW_IMAGE" --format '{{.Size}}')" + if docker pull --platform linux/amd64 "$BASE_IMAGE" >/dev/null 2>&1; then + current_bytes="$(docker image inspect "$BASE_IMAGE" --format '{{.Size}}')" + else + current_bytes="" + echo "No published baseline image found for ${BASE_IMAGE}" + fi + jq -n \ + --argjson matrix "$MATRIX_JSON" \ + --arg base "$BASE_IMAGE" \ + --arg current "$current_bytes" \ + --arg new "$new_bytes" \ + '{matrix: $matrix, baseImage: $base, currentBytes: $current, newBytes: $new}' \ + > "size-report/${id}.json" + echo "id=${id}" >> "$GITHUB_OUTPUT" + + - name: Upload image size report + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: image-size-${{ steps.image-size.outputs.id }} + path: size-report/ + retention-days: 1 + - name: Login to DockerHub if: github.event_name != 'pull_request' uses: docker/login-action@v4 @@ -299,3 +334,41 @@ jobs: cache-from: | type=gha,scope=${{ matrix.image-name }}-${{ matrix.node-version }}-${{ matrix.puppeteer-version }}-slim type=gha,scope=${{ matrix.image-name }}-${{ matrix.node-version }}-${{ matrix.puppeteer-version }} + + # Aggregate the per-image size reports uploaded by the build matrix and post/update + # a single sticky PR comment comparing current vs new image sizes. + size-report: + name: Report image size changes + needs: [build-main] + if: ${{ always() && github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download image size reports + uses: actions/download-artifact@v8 + continue-on-error: true + with: + pattern: image-size-* + path: size-reports + merge-multiple: true + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Render comment body + run: | + mkdir -p size-reports + node .github/scripts/format-image-size-report.ts size-reports | tee image-size-comment.md + + - name: Post or update image size comment + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: image-sizes-node-puppeteer + path: image-size-comment.md diff --git a/.github/workflows/release-node.yaml b/.github/workflows/release-node.yaml index 342008ea..9fde69e5 100644 --- a/.github/workflows/release-node.yaml +++ b/.github/workflows/release-node.yaml @@ -249,6 +249,41 @@ jobs: - name: Test slim image run: docker run ${{ fromJson(steps.prepare-slim-tags.outputs.result).firstImageName }} + - name: Measure image size + id: image-size + if: github.event_name == 'pull_request' + env: + MATRIX_JSON: ${{ toJSON(matrix) }} + NEW_IMAGE: ${{ fromJson(steps.prepare-tags.outputs.result).firstImageName }} + BASE_IMAGE: apify/actor-${{ matrix.image-name }}:${{ matrix.node-version }} + run: | + set -euo pipefail + mkdir -p size-report + id="$(printf '%s' "$MATRIX_JSON" | sha256sum | cut -c1-16)" + new_bytes="$(docker image inspect "$NEW_IMAGE" --format '{{.Size}}')" + if docker pull --platform linux/amd64 "$BASE_IMAGE" >/dev/null 2>&1; then + current_bytes="$(docker image inspect "$BASE_IMAGE" --format '{{.Size}}')" + else + current_bytes="" + echo "No published baseline image found for ${BASE_IMAGE}" + fi + jq -n \ + --argjson matrix "$MATRIX_JSON" \ + --arg base "$BASE_IMAGE" \ + --arg current "$current_bytes" \ + --arg new "$new_bytes" \ + '{matrix: $matrix, baseImage: $base, currentBytes: $current, newBytes: $new}' \ + > "size-report/${id}.json" + echo "id=${id}" >> "$GITHUB_OUTPUT" + + - name: Upload image size report + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: image-size-${{ steps.image-size.outputs.id }} + path: size-report/ + retention-days: 1 + - name: Login to DockerHub if: github.event_name != 'pull_request' uses: docker/login-action@v4 @@ -287,3 +322,41 @@ jobs: cache-from: | type=gha,scope=${{ matrix.image-name }}-${{ matrix.node-version }}-slim type=gha,scope=${{ matrix.image-name }}-${{ matrix.node-version }} + + # Aggregate the per-image size reports uploaded by the build matrix and post/update + # a single sticky PR comment comparing current vs new image sizes. + size-report: + name: Report image size changes + needs: [build-main] + if: ${{ always() && github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download image size reports + uses: actions/download-artifact@v8 + continue-on-error: true + with: + pattern: image-size-* + path: size-reports + merge-multiple: true + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Render comment body + run: | + mkdir -p size-reports + node .github/scripts/format-image-size-report.ts size-reports | tee image-size-comment.md + + - name: Post or update image size comment + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: image-sizes-node + path: image-size-comment.md diff --git a/.github/workflows/release-python-playwright.yaml b/.github/workflows/release-python-playwright.yaml index cc057b3e..abe42cbb 100644 --- a/.github/workflows/release-python-playwright.yaml +++ b/.github/workflows/release-python-playwright.yaml @@ -187,6 +187,41 @@ jobs: - name: Test image run: docker run ${{ fromJson(steps.prepare-tags.outputs.result).firstImageName }} + - name: Measure image size + id: image-size + if: github.event_name == 'pull_request' + env: + MATRIX_JSON: ${{ toJSON(matrix) }} + NEW_IMAGE: ${{ fromJson(steps.prepare-tags.outputs.result).firstImageName }} + BASE_IMAGE: apify/actor-${{ matrix.image-name }}:${{ matrix.python-version }} + run: | + set -euo pipefail + mkdir -p size-report + id="$(printf '%s' "$MATRIX_JSON" | sha256sum | cut -c1-16)" + new_bytes="$(docker image inspect "$NEW_IMAGE" --format '{{.Size}}')" + if docker pull --platform linux/amd64 "$BASE_IMAGE" >/dev/null 2>&1; then + current_bytes="$(docker image inspect "$BASE_IMAGE" --format '{{.Size}}')" + else + current_bytes="" + echo "No published baseline image found for ${BASE_IMAGE}" + fi + jq -n \ + --argjson matrix "$MATRIX_JSON" \ + --arg base "$BASE_IMAGE" \ + --arg current "$current_bytes" \ + --arg new "$new_bytes" \ + '{matrix: $matrix, baseImage: $base, currentBytes: $current, newBytes: $new}' \ + > "size-report/${id}.json" + echo "id=${id}" >> "$GITHUB_OUTPUT" + + - name: Upload image size report + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: image-size-${{ steps.image-size.outputs.id }} + path: size-report/ + retention-days: 1 + - name: Login to DockerHub if: github.event_name != 'pull_request' uses: docker/login-action@v4 @@ -210,3 +245,41 @@ jobs: tags: ${{ fromJson(steps.prepare-tags.outputs.result).allTags }} outputs: type=image,oci-mediatypes=true cache-from: type=gha,scope=${{ matrix.image-name }}-${{ matrix.python-version }}-${{ matrix.playwright-version }}-${{ matrix.camoufox-version }} + + # Aggregate the per-image size reports uploaded by the build matrix and post/update + # a single sticky PR comment comparing current vs new image sizes. + size-report: + name: Report image size changes + needs: [build-main] + if: ${{ always() && github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download image size reports + uses: actions/download-artifact@v8 + continue-on-error: true + with: + pattern: image-size-* + path: size-reports + merge-multiple: true + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Render comment body + run: | + mkdir -p size-reports + node .github/scripts/format-image-size-report.ts size-reports | tee image-size-comment.md + + - name: Post or update image size comment + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: image-sizes-python-playwright + path: image-size-comment.md diff --git a/.github/workflows/release-python-selenium.yaml b/.github/workflows/release-python-selenium.yaml index 25758b57..0fc09135 100644 --- a/.github/workflows/release-python-selenium.yaml +++ b/.github/workflows/release-python-selenium.yaml @@ -163,6 +163,41 @@ jobs: - name: Test image run: docker run ${{ fromJson(steps.prepare-tags.outputs.result).firstImageName }} + - name: Measure image size + id: image-size + if: github.event_name == 'pull_request' + env: + MATRIX_JSON: ${{ toJSON(matrix) }} + NEW_IMAGE: ${{ fromJson(steps.prepare-tags.outputs.result).firstImageName }} + BASE_IMAGE: apify/actor-${{ matrix.image-name }}:${{ matrix.python-version }} + run: | + set -euo pipefail + mkdir -p size-report + id="$(printf '%s' "$MATRIX_JSON" | sha256sum | cut -c1-16)" + new_bytes="$(docker image inspect "$NEW_IMAGE" --format '{{.Size}}')" + if docker pull --platform linux/amd64 "$BASE_IMAGE" >/dev/null 2>&1; then + current_bytes="$(docker image inspect "$BASE_IMAGE" --format '{{.Size}}')" + else + current_bytes="" + echo "No published baseline image found for ${BASE_IMAGE}" + fi + jq -n \ + --argjson matrix "$MATRIX_JSON" \ + --arg base "$BASE_IMAGE" \ + --arg current "$current_bytes" \ + --arg new "$new_bytes" \ + '{matrix: $matrix, baseImage: $base, currentBytes: $current, newBytes: $new}' \ + > "size-report/${id}.json" + echo "id=${id}" >> "$GITHUB_OUTPUT" + + - name: Upload image size report + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: image-size-${{ steps.image-size.outputs.id }} + path: size-report/ + retention-days: 1 + - name: Login to DockerHub if: github.event_name != 'pull_request' uses: docker/login-action@v4 @@ -185,3 +220,41 @@ jobs: tags: ${{ fromJson(steps.prepare-tags.outputs.result).allTags }} outputs: type=image,oci-mediatypes=true cache-from: type=gha,scope=${{ matrix.image-name }}-${{ matrix.python-version }}-${{ matrix.selenium-version }} + + # Aggregate the per-image size reports uploaded by the build matrix and post/update + # a single sticky PR comment comparing current vs new image sizes. + size-report: + name: Report image size changes + needs: [build-main] + if: ${{ always() && github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download image size reports + uses: actions/download-artifact@v8 + continue-on-error: true + with: + pattern: image-size-* + path: size-reports + merge-multiple: true + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Render comment body + run: | + mkdir -p size-reports + node .github/scripts/format-image-size-report.ts size-reports | tee image-size-comment.md + + - name: Post or update image size comment + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: image-sizes-python-selenium + path: image-size-comment.md diff --git a/.github/workflows/release-python.yaml b/.github/workflows/release-python.yaml index b84e3fee..d4611cac 100644 --- a/.github/workflows/release-python.yaml +++ b/.github/workflows/release-python.yaml @@ -161,6 +161,41 @@ jobs: - name: Test image run: docker run ${{ fromJson(steps.prepare-tags.outputs.result).firstImageName }} + - name: Measure image size + id: image-size + if: github.event_name == 'pull_request' + env: + MATRIX_JSON: ${{ toJSON(matrix) }} + NEW_IMAGE: ${{ fromJson(steps.prepare-tags.outputs.result).firstImageName }} + BASE_IMAGE: apify/actor-${{ matrix.image-name }}:${{ matrix.python-version }} + run: | + set -euo pipefail + mkdir -p size-report + id="$(printf '%s' "$MATRIX_JSON" | sha256sum | cut -c1-16)" + new_bytes="$(docker image inspect "$NEW_IMAGE" --format '{{.Size}}')" + if docker pull --platform linux/amd64 "$BASE_IMAGE" >/dev/null 2>&1; then + current_bytes="$(docker image inspect "$BASE_IMAGE" --format '{{.Size}}')" + else + current_bytes="" + echo "No published baseline image found for ${BASE_IMAGE}" + fi + jq -n \ + --argjson matrix "$MATRIX_JSON" \ + --arg base "$BASE_IMAGE" \ + --arg current "$current_bytes" \ + --arg new "$new_bytes" \ + '{matrix: $matrix, baseImage: $base, currentBytes: $current, newBytes: $new}' \ + > "size-report/${id}.json" + echo "id=${id}" >> "$GITHUB_OUTPUT" + + - name: Upload image size report + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: image-size-${{ steps.image-size.outputs.id }} + path: size-report/ + retention-days: 1 + - name: Login to DockerHub if: github.event_name != 'pull_request' uses: docker/login-action@v4 @@ -182,3 +217,41 @@ jobs: tags: ${{ fromJson(steps.prepare-tags.outputs.result).allTags }} outputs: type=image,oci-mediatypes=true cache-from: type=gha,scope=${{ matrix.image-name }}-${{ matrix.python-version }} + + # Aggregate the per-image size reports uploaded by the build matrix and post/update + # a single sticky PR comment comparing current vs new image sizes. + size-report: + name: Report image size changes + needs: [build-main] + if: ${{ always() && github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download image size reports + uses: actions/download-artifact@v8 + continue-on-error: true + with: + pattern: image-size-* + path: size-reports + merge-multiple: true + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Render comment body + run: | + mkdir -p size-reports + node .github/scripts/format-image-size-report.ts size-reports | tee image-size-comment.md + + - name: Post or update image size comment + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: image-sizes-python + path: image-size-comment.md From db26898a7065acb5d057890ed90d686cfa856a9f Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 31 Aug 2026 18:08:35 +0300 Subject: [PATCH 2/3] ci: include -slim variants in the image size report The node workflows' measure step now records the slim image alongside the regular one (baseline: the published rolling tag + -slim), and the report renderer surfaces an explicit variant label next to the matrix-derived one. Python workflows are unchanged as they have no slim variants. --- .github/scripts/format-image-size-report.ts | 4 +- .../workflows/release-node-playwright.yaml | 37 ++++++++++++------- .github/workflows/release-node-puppeteer.yaml | 37 ++++++++++++------- .github/workflows/release-node.yaml | 37 ++++++++++++------- 4 files changed, 72 insertions(+), 43 deletions(-) diff --git a/.github/scripts/format-image-size-report.ts b/.github/scripts/format-image-size-report.ts index fd199a1e..6342dcbc 100644 --- a/.github/scripts/format-image-size-report.ts +++ b/.github/scripts/format-image-size-report.ts @@ -13,6 +13,8 @@ import { join } from 'node:path'; interface SizeReport { matrix?: Record; baseImage?: string; + // Extra variant label not derivable from the matrix (e.g. "slim"). + variant?: string; currentBytes?: string; newBytes?: string; } @@ -100,7 +102,7 @@ function render(reports: SizeReport[]): string { const rows = reports .map((report) => ({ image: report.baseImage || '(unknown)', - variant: describeVariant(report.matrix), + variant: [describeVariant(report.matrix), report.variant].filter(Boolean).join(', '), current: formatSize(report.currentBytes) ?? '_n/a_', next: formatSize(report.newBytes) ?? '_n/a_', delta: formatDelta(report.currentBytes, report.newBytes), diff --git a/.github/workflows/release-node-playwright.yaml b/.github/workflows/release-node-playwright.yaml index 6a453eb9..8412fc95 100644 --- a/.github/workflows/release-node-playwright.yaml +++ b/.github/workflows/release-node-playwright.yaml @@ -285,24 +285,33 @@ jobs: MATRIX_JSON: ${{ toJSON(matrix) }} NEW_IMAGE: ${{ fromJson(steps.prepare-tags.outputs.result).firstImageName }} BASE_IMAGE: apify/actor-${{ matrix.image-name }}:${{ matrix.node-version }} + NEW_SLIM_IMAGE: ${{ fromJson(steps.prepare-slim-tags.outputs.result).firstImageName }} + BASE_SLIM_IMAGE: apify/actor-${{ matrix.image-name }}:${{ matrix.node-version }}-slim run: | set -euo pipefail mkdir -p size-report id="$(printf '%s' "$MATRIX_JSON" | sha256sum | cut -c1-16)" - new_bytes="$(docker image inspect "$NEW_IMAGE" --format '{{.Size}}')" - if docker pull --platform linux/amd64 "$BASE_IMAGE" >/dev/null 2>&1; then - current_bytes="$(docker image inspect "$BASE_IMAGE" --format '{{.Size}}')" - else - current_bytes="" - echo "No published baseline image found for ${BASE_IMAGE}" - fi - jq -n \ - --argjson matrix "$MATRIX_JSON" \ - --arg base "$BASE_IMAGE" \ - --arg current "$current_bytes" \ - --arg new "$new_bytes" \ - '{matrix: $matrix, baseImage: $base, currentBytes: $current, newBytes: $new}' \ - > "size-report/${id}.json" + measure() { + local new_image="$1" base_image="$2" variant="$3" out="$4" + local new_bytes current_bytes + new_bytes="$(docker image inspect "$new_image" --format '{{.Size}}')" + if docker pull --platform linux/amd64 "$base_image" >/dev/null 2>&1; then + current_bytes="$(docker image inspect "$base_image" --format '{{.Size}}')" + else + current_bytes="" + echo "No published baseline image found for ${base_image}" + fi + jq -n \ + --argjson matrix "$MATRIX_JSON" \ + --arg base "$base_image" \ + --arg variant "$variant" \ + --arg current "$current_bytes" \ + --arg new "$new_bytes" \ + '{matrix: $matrix, baseImage: $base, variant: $variant, currentBytes: $current, newBytes: $new}' \ + > "size-report/${out}.json" + } + measure "$NEW_IMAGE" "$BASE_IMAGE" "" "$id" + measure "$NEW_SLIM_IMAGE" "$BASE_SLIM_IMAGE" "slim" "${id}-slim" echo "id=${id}" >> "$GITHUB_OUTPUT" - name: Upload image size report diff --git a/.github/workflows/release-node-puppeteer.yaml b/.github/workflows/release-node-puppeteer.yaml index 824d8f6e..0987ed00 100644 --- a/.github/workflows/release-node-puppeteer.yaml +++ b/.github/workflows/release-node-puppeteer.yaml @@ -265,24 +265,33 @@ jobs: MATRIX_JSON: ${{ toJSON(matrix) }} NEW_IMAGE: ${{ fromJson(steps.prepare-tags.outputs.result).firstImageName }} BASE_IMAGE: apify/actor-${{ matrix.image-name }}:${{ matrix.node-version }} + NEW_SLIM_IMAGE: ${{ fromJson(steps.prepare-slim-tags.outputs.result).firstImageName }} + BASE_SLIM_IMAGE: apify/actor-${{ matrix.image-name }}:${{ matrix.node-version }}-slim run: | set -euo pipefail mkdir -p size-report id="$(printf '%s' "$MATRIX_JSON" | sha256sum | cut -c1-16)" - new_bytes="$(docker image inspect "$NEW_IMAGE" --format '{{.Size}}')" - if docker pull --platform linux/amd64 "$BASE_IMAGE" >/dev/null 2>&1; then - current_bytes="$(docker image inspect "$BASE_IMAGE" --format '{{.Size}}')" - else - current_bytes="" - echo "No published baseline image found for ${BASE_IMAGE}" - fi - jq -n \ - --argjson matrix "$MATRIX_JSON" \ - --arg base "$BASE_IMAGE" \ - --arg current "$current_bytes" \ - --arg new "$new_bytes" \ - '{matrix: $matrix, baseImage: $base, currentBytes: $current, newBytes: $new}' \ - > "size-report/${id}.json" + measure() { + local new_image="$1" base_image="$2" variant="$3" out="$4" + local new_bytes current_bytes + new_bytes="$(docker image inspect "$new_image" --format '{{.Size}}')" + if docker pull --platform linux/amd64 "$base_image" >/dev/null 2>&1; then + current_bytes="$(docker image inspect "$base_image" --format '{{.Size}}')" + else + current_bytes="" + echo "No published baseline image found for ${base_image}" + fi + jq -n \ + --argjson matrix "$MATRIX_JSON" \ + --arg base "$base_image" \ + --arg variant "$variant" \ + --arg current "$current_bytes" \ + --arg new "$new_bytes" \ + '{matrix: $matrix, baseImage: $base, variant: $variant, currentBytes: $current, newBytes: $new}' \ + > "size-report/${out}.json" + } + measure "$NEW_IMAGE" "$BASE_IMAGE" "" "$id" + measure "$NEW_SLIM_IMAGE" "$BASE_SLIM_IMAGE" "slim" "${id}-slim" echo "id=${id}" >> "$GITHUB_OUTPUT" - name: Upload image size report diff --git a/.github/workflows/release-node.yaml b/.github/workflows/release-node.yaml index 9fde69e5..47edc6e7 100644 --- a/.github/workflows/release-node.yaml +++ b/.github/workflows/release-node.yaml @@ -256,24 +256,33 @@ jobs: MATRIX_JSON: ${{ toJSON(matrix) }} NEW_IMAGE: ${{ fromJson(steps.prepare-tags.outputs.result).firstImageName }} BASE_IMAGE: apify/actor-${{ matrix.image-name }}:${{ matrix.node-version }} + NEW_SLIM_IMAGE: ${{ fromJson(steps.prepare-slim-tags.outputs.result).firstImageName }} + BASE_SLIM_IMAGE: apify/actor-${{ matrix.image-name }}:${{ matrix.node-version }}-slim run: | set -euo pipefail mkdir -p size-report id="$(printf '%s' "$MATRIX_JSON" | sha256sum | cut -c1-16)" - new_bytes="$(docker image inspect "$NEW_IMAGE" --format '{{.Size}}')" - if docker pull --platform linux/amd64 "$BASE_IMAGE" >/dev/null 2>&1; then - current_bytes="$(docker image inspect "$BASE_IMAGE" --format '{{.Size}}')" - else - current_bytes="" - echo "No published baseline image found for ${BASE_IMAGE}" - fi - jq -n \ - --argjson matrix "$MATRIX_JSON" \ - --arg base "$BASE_IMAGE" \ - --arg current "$current_bytes" \ - --arg new "$new_bytes" \ - '{matrix: $matrix, baseImage: $base, currentBytes: $current, newBytes: $new}' \ - > "size-report/${id}.json" + measure() { + local new_image="$1" base_image="$2" variant="$3" out="$4" + local new_bytes current_bytes + new_bytes="$(docker image inspect "$new_image" --format '{{.Size}}')" + if docker pull --platform linux/amd64 "$base_image" >/dev/null 2>&1; then + current_bytes="$(docker image inspect "$base_image" --format '{{.Size}}')" + else + current_bytes="" + echo "No published baseline image found for ${base_image}" + fi + jq -n \ + --argjson matrix "$MATRIX_JSON" \ + --arg base "$base_image" \ + --arg variant "$variant" \ + --arg current "$current_bytes" \ + --arg new "$new_bytes" \ + '{matrix: $matrix, baseImage: $base, variant: $variant, currentBytes: $current, newBytes: $new}' \ + > "size-report/${out}.json" + } + measure "$NEW_IMAGE" "$BASE_IMAGE" "" "$id" + measure "$NEW_SLIM_IMAGE" "$BASE_SLIM_IMAGE" "slim" "${id}-slim" echo "id=${id}" >> "$GITHUB_OUTPUT" - name: Upload image size report From 1324b28c0f517f8b2fcb1ed297b31df4a3dcb0da Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 31 Aug 2026 18:11:35 +0300 Subject: [PATCH 3/3] ci: merge image size reports into one PR comment with foldable sections Instead of one sticky comment per workflow (up to 6 on a wide PR), all image workflows now upsert their own marker-delimited
section into a single shared comment. The size-report jobs share a repo-wide concurrency group so concurrent read-modify-writes of the comment cannot drop sections. Sections only appear for workflows the PR's changed paths actually triggered, and each is stamped with the head SHA it measured. --- .github/scripts/format-image-size-report.ts | 27 ++++++----- .github/scripts/upsert-image-size-comment.js | 48 +++++++++++++++++++ .../workflows/release-node-playwright.yaml | 19 ++++++-- .github/workflows/release-node-puppeteer.yaml | 19 ++++++-- .github/workflows/release-node.yaml | 19 ++++++-- .../workflows/release-python-playwright.yaml | 19 ++++++-- .../workflows/release-python-selenium.yaml | 19 ++++++-- .github/workflows/release-python.yaml | 19 ++++++-- 8 files changed, 147 insertions(+), 42 deletions(-) create mode 100644 .github/scripts/upsert-image-size-comment.js diff --git a/.github/scripts/format-image-size-report.ts b/.github/scripts/format-image-size-report.ts index 6342dcbc..6a124b18 100644 --- a/.github/scripts/format-image-size-report.ts +++ b/.github/scripts/format-image-size-report.ts @@ -1,6 +1,8 @@ -// Renders the Markdown body for the "image size" PR comment. +// Renders one workflow's
section of the shared "image size" PR comment. +// The section is upserted into a single sticky comment by upsert-image-size-comment.js, +// so each image workflow only ever touches its own foldable block. // -// Usage: node format-image-size-report.ts +// Usage: node format-image-size-report.ts [head-sha] // Runs natively on Node.js >=24 via built-in TypeScript type stripping (no build step). // // contains one JSON file per built image (uploaded as artifacts by @@ -79,22 +81,22 @@ function describeVariant(matrix: Record = {}): string { return variants.join(', '); } -function render(reports: SizeReport[]): string { +function render(reports: SizeReport[], title: string, headSha: string): string { const lines: string[] = []; - lines.push('### 📦 Image size report'); - lines.push(''); + const measuredAt = headSha ? ` (at ${headSha.slice(0, 7)})` : ''; if (reports.length === 0) { - lines.push('No image size data was collected (no images were built in this run).'); + lines.push('
'); + lines.push(`${title} — no image size data collected${measuredAt}`); + lines.push(''); + lines.push('No images were built in this run.'); + lines.push('
'); lines.push(''); return lines.join('\n'); } - lines.push( - 'Built images compared against the currently published rolling tag for the same runtime version ' - + '(e.g. `apify/actor-node:22`). Sizes are the **uncompressed** on-disk size reported by ' - + '`docker image inspect`, so they will be larger than the compressed download size shown on Docker Hub.', - ); + lines.push('
'); + lines.push(`${title} — ${reports.length} image${reports.length === 1 ? '' : 's'}${measuredAt}`); lines.push(''); lines.push('| Image | Variant | Current | New | Δ |'); lines.push('| --- | --- | ---: | ---: | --- |'); @@ -113,8 +115,9 @@ function render(reports: SizeReport[]): string { lines.push(`| \`${row.image}\` | ${row.variant || '—'} | ${row.current} | ${row.next} | ${row.delta} |`); } + lines.push('
'); lines.push(''); return lines.join('\n'); } -process.stdout.write(render(readReports(process.argv[2] ?? ''))); +process.stdout.write(render(readReports(process.argv[2] ?? ''), process.argv[3] ?? 'Image sizes', process.argv[4] ?? '')); diff --git a/.github/scripts/upsert-image-size-comment.js b/.github/scripts/upsert-image-size-comment.js new file mode 100644 index 00000000..109fa488 --- /dev/null +++ b/.github/scripts/upsert-image-size-comment.js @@ -0,0 +1,48 @@ +// Upserts one workflow's section into the single shared "image size" PR comment. +// +// Every image workflow renders its own
block (format-image-size-report.ts) and +// calls this from actions/github-script. The comment is identified by MARKER; each section +// is delimited by per-workflow markers so a workflow only ever rewrites its own block. +// Concurrent writers are serialized by the size-report jobs sharing a repo-wide +// `concurrency` group, so the read-modify-write below cannot lose sections. + +const MARKER = ''; +const PREAMBLE = `${MARKER} +### 📦 Image size report + +Built images compared against the currently published rolling tag for the same runtime \ +version (e.g. \`apify/actor-node:22\`; \`-slim\` variants against the \`-slim\` tag). Sizes \ +are the **uncompressed** on-disk size reported by \`docker image inspect\`, so they will be \ +larger than the compressed download size shown on Docker Hub. Only workflows triggered by \ +this PR's changes report a section. +`; + +module.exports = async ({ github, context }, sectionKey, sectionBody) => { + if (!/^[a-z][a-z0-9-]*$/.test(sectionKey)) throw new Error(`Invalid section key: ${sectionKey}`); + + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const begin = ``; + const end = ``; + const section = `${begin}\n${sectionBody.trim()}\n${end}`; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + const existing = comments.find((comment) => comment.body?.includes(MARKER)); + + if (!existing) { + await github.rest.issues.createComment({ owner, repo, issue_number, body: `${PREAMBLE}\n${section}\n` }); + return; + } + + const sectionPattern = new RegExp(`${begin}[\\s\\S]*?${end}`); + const body = sectionPattern.test(existing.body) + ? existing.body.replace(sectionPattern, section) + : `${existing.body.trimEnd()}\n\n${section}\n`; + + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); +}; diff --git a/.github/workflows/release-node-playwright.yaml b/.github/workflows/release-node-playwright.yaml index 8412fc95..26bbaeec 100644 --- a/.github/workflows/release-node-playwright.yaml +++ b/.github/workflows/release-node-playwright.yaml @@ -373,6 +373,11 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + # All image workflows update sections of ONE shared PR comment; the repo-wide + # concurrency group serializes their read-modify-write so no section is lost. + concurrency: + group: image-size-comment-${{ github.event.pull_request.number }} + cancel-in-progress: false steps: - name: Checkout @@ -391,13 +396,17 @@ jobs: with: node-version: 24 - - name: Render comment body + - name: Render comment section run: | mkdir -p size-reports - node .github/scripts/format-image-size-report.ts size-reports | tee image-size-comment.md + node .github/scripts/format-image-size-report.ts size-reports "Node + Playwright images" "${{ github.event.pull_request.head.sha }}" | tee image-size-comment.md - name: Post or update image size comment - uses: marocchino/sticky-pull-request-comment@v3 + uses: actions/github-script@v8 + env: + SECTION_KEY: node-playwright with: - header: image-sizes-node-playwright - path: image-size-comment.md + script: | + const { readFileSync } = require('node:fs'); + const upsertSection = require('./.github/scripts/upsert-image-size-comment.js'); + await upsertSection({ github, context }, process.env.SECTION_KEY, readFileSync('image-size-comment.md', 'utf8')); diff --git a/.github/workflows/release-node-puppeteer.yaml b/.github/workflows/release-node-puppeteer.yaml index 0987ed00..c905ab53 100644 --- a/.github/workflows/release-node-puppeteer.yaml +++ b/.github/workflows/release-node-puppeteer.yaml @@ -353,6 +353,11 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + # All image workflows update sections of ONE shared PR comment; the repo-wide + # concurrency group serializes their read-modify-write so no section is lost. + concurrency: + group: image-size-comment-${{ github.event.pull_request.number }} + cancel-in-progress: false steps: - name: Checkout @@ -371,13 +376,17 @@ jobs: with: node-version: 24 - - name: Render comment body + - name: Render comment section run: | mkdir -p size-reports - node .github/scripts/format-image-size-report.ts size-reports | tee image-size-comment.md + node .github/scripts/format-image-size-report.ts size-reports "Node + Puppeteer images" "${{ github.event.pull_request.head.sha }}" | tee image-size-comment.md - name: Post or update image size comment - uses: marocchino/sticky-pull-request-comment@v3 + uses: actions/github-script@v8 + env: + SECTION_KEY: node-puppeteer with: - header: image-sizes-node-puppeteer - path: image-size-comment.md + script: | + const { readFileSync } = require('node:fs'); + const upsertSection = require('./.github/scripts/upsert-image-size-comment.js'); + await upsertSection({ github, context }, process.env.SECTION_KEY, readFileSync('image-size-comment.md', 'utf8')); diff --git a/.github/workflows/release-node.yaml b/.github/workflows/release-node.yaml index 47edc6e7..40ea779c 100644 --- a/.github/workflows/release-node.yaml +++ b/.github/workflows/release-node.yaml @@ -341,6 +341,11 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + # All image workflows update sections of ONE shared PR comment; the repo-wide + # concurrency group serializes their read-modify-write so no section is lost. + concurrency: + group: image-size-comment-${{ github.event.pull_request.number }} + cancel-in-progress: false steps: - name: Checkout @@ -359,13 +364,17 @@ jobs: with: node-version: 24 - - name: Render comment body + - name: Render comment section run: | mkdir -p size-reports - node .github/scripts/format-image-size-report.ts size-reports | tee image-size-comment.md + node .github/scripts/format-image-size-report.ts size-reports "Node basic images" "${{ github.event.pull_request.head.sha }}" | tee image-size-comment.md - name: Post or update image size comment - uses: marocchino/sticky-pull-request-comment@v3 + uses: actions/github-script@v8 + env: + SECTION_KEY: node with: - header: image-sizes-node - path: image-size-comment.md + script: | + const { readFileSync } = require('node:fs'); + const upsertSection = require('./.github/scripts/upsert-image-size-comment.js'); + await upsertSection({ github, context }, process.env.SECTION_KEY, readFileSync('image-size-comment.md', 'utf8')); diff --git a/.github/workflows/release-python-playwright.yaml b/.github/workflows/release-python-playwright.yaml index abe42cbb..18f7ec74 100644 --- a/.github/workflows/release-python-playwright.yaml +++ b/.github/workflows/release-python-playwright.yaml @@ -255,6 +255,11 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + # All image workflows update sections of ONE shared PR comment; the repo-wide + # concurrency group serializes their read-modify-write so no section is lost. + concurrency: + group: image-size-comment-${{ github.event.pull_request.number }} + cancel-in-progress: false steps: - name: Checkout @@ -273,13 +278,17 @@ jobs: with: node-version: 24 - - name: Render comment body + - name: Render comment section run: | mkdir -p size-reports - node .github/scripts/format-image-size-report.ts size-reports | tee image-size-comment.md + node .github/scripts/format-image-size-report.ts size-reports "Python + Playwright images" "${{ github.event.pull_request.head.sha }}" | tee image-size-comment.md - name: Post or update image size comment - uses: marocchino/sticky-pull-request-comment@v3 + uses: actions/github-script@v8 + env: + SECTION_KEY: python-playwright with: - header: image-sizes-python-playwright - path: image-size-comment.md + script: | + const { readFileSync } = require('node:fs'); + const upsertSection = require('./.github/scripts/upsert-image-size-comment.js'); + await upsertSection({ github, context }, process.env.SECTION_KEY, readFileSync('image-size-comment.md', 'utf8')); diff --git a/.github/workflows/release-python-selenium.yaml b/.github/workflows/release-python-selenium.yaml index 0fc09135..3a253975 100644 --- a/.github/workflows/release-python-selenium.yaml +++ b/.github/workflows/release-python-selenium.yaml @@ -230,6 +230,11 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + # All image workflows update sections of ONE shared PR comment; the repo-wide + # concurrency group serializes their read-modify-write so no section is lost. + concurrency: + group: image-size-comment-${{ github.event.pull_request.number }} + cancel-in-progress: false steps: - name: Checkout @@ -248,13 +253,17 @@ jobs: with: node-version: 24 - - name: Render comment body + - name: Render comment section run: | mkdir -p size-reports - node .github/scripts/format-image-size-report.ts size-reports | tee image-size-comment.md + node .github/scripts/format-image-size-report.ts size-reports "Python + Selenium images" "${{ github.event.pull_request.head.sha }}" | tee image-size-comment.md - name: Post or update image size comment - uses: marocchino/sticky-pull-request-comment@v3 + uses: actions/github-script@v8 + env: + SECTION_KEY: python-selenium with: - header: image-sizes-python-selenium - path: image-size-comment.md + script: | + const { readFileSync } = require('node:fs'); + const upsertSection = require('./.github/scripts/upsert-image-size-comment.js'); + await upsertSection({ github, context }, process.env.SECTION_KEY, readFileSync('image-size-comment.md', 'utf8')); diff --git a/.github/workflows/release-python.yaml b/.github/workflows/release-python.yaml index d4611cac..ba9f8d80 100644 --- a/.github/workflows/release-python.yaml +++ b/.github/workflows/release-python.yaml @@ -227,6 +227,11 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + # All image workflows update sections of ONE shared PR comment; the repo-wide + # concurrency group serializes their read-modify-write so no section is lost. + concurrency: + group: image-size-comment-${{ github.event.pull_request.number }} + cancel-in-progress: false steps: - name: Checkout @@ -245,13 +250,17 @@ jobs: with: node-version: 24 - - name: Render comment body + - name: Render comment section run: | mkdir -p size-reports - node .github/scripts/format-image-size-report.ts size-reports | tee image-size-comment.md + node .github/scripts/format-image-size-report.ts size-reports "Python basic images" "${{ github.event.pull_request.head.sha }}" | tee image-size-comment.md - name: Post or update image size comment - uses: marocchino/sticky-pull-request-comment@v3 + uses: actions/github-script@v8 + env: + SECTION_KEY: python with: - header: image-sizes-python - path: image-size-comment.md + script: | + const { readFileSync } = require('node:fs'); + const upsertSection = require('./.github/scripts/upsert-image-size-comment.js'); + await upsertSection({ github, context }, process.env.SECTION_KEY, readFileSync('image-size-comment.md', 'utf8'));