Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions .github/scripts/format-image-size-report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Renders one workflow's <details> 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 <reports-dir> <section-title> [head-sha]
// Runs natively on Node.js >=24 via built-in TypeScript type stripping (no build step).
//
// <reports-dir> 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<string, string>;
baseImage?: string;
// Extra variant label not derivable from the matrix (e.g. "slim").
variant?: string;
currentBytes?: string;
newBytes?: string;
}

// Matrix keys we never want to surface as a "variant" in the table.
const IGNORED_VERSION_KEYS = new Set<string>([
'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, string> = {}): 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[], title: string, headSha: string): string {
const lines: string[] = [];
const measuredAt = headSha ? ` <sub>(at ${headSha.slice(0, 7)})</sub>` : '';

if (reports.length === 0) {
lines.push('<details>');
lines.push(`<summary><b>${title}</b> — no image size data collected${measuredAt}</summary>`);
lines.push('');
lines.push('No images were built in this run.');
lines.push('</details>');
lines.push('');
return lines.join('\n');
}

lines.push('<details>');
lines.push(`<summary><b>${title}</b> — ${reports.length} image${reports.length === 1 ? '' : 's'}${measuredAt}</summary>`);
lines.push('');
lines.push('| Image | Variant | Current | New | Δ |');
lines.push('| --- | --- | ---: | ---: | --- |');

const rows = reports
.map((report) => ({
image: report.baseImage || '(unknown)',
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),
}))
.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('</details>');
lines.push('');
return lines.join('\n');
}

process.stdout.write(render(readReports(process.argv[2] ?? ''), process.argv[3] ?? 'Image sizes', process.argv[4] ?? ''));
48 changes: 48 additions & 0 deletions .github/scripts/upsert-image-size-comment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Upserts one workflow's section into the single shared "image size" PR comment.
//
// Every image workflow renders its own <details> 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 = '<!-- image-size-report -->';
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 = `<!-- section:${sectionKey} -->`;
const end = `<!-- /section:${sectionKey} -->`;
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 });
};
91 changes: 91 additions & 0 deletions .github/workflows/release-node-playwright.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,50 @@ 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 }}
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)"
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
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
Expand Down Expand Up @@ -319,3 +363,50 @@ 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
# 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
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 section
run: |
mkdir -p size-reports
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: actions/github-script@v8
env:
SECTION_KEY: node-playwright
with:
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'));
91 changes: 91 additions & 0 deletions .github/workflows/release-node-puppeteer.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,50 @@ 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 }}
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)"
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
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
Expand Down Expand Up @@ -299,3 +343,50 @@ 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
# 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
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 section
run: |
mkdir -p size-reports
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: actions/github-script@v8
env:
SECTION_KEY: node-puppeteer
with:
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'));
Loading
Loading