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
6 changes: 6 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ inputs:
description: 'The GitHub access token'
required: false
default: ${{ github.token }}
max-image-width:
description: 'Maximum width for uploaded images (in pixels). If not provided, images will not be resized. Recommended: 1500.'
required: false
max-image-height:
description: 'Maximum height for uploaded images (in pixels). If not provided, images will not be resized. Recommended: 1500.'
required: false
runs:
using: 'node20'
main: 'action/dist/main.js'
Expand Down
6,883 changes: 6,766 additions & 117 deletions action/dist/main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion action/dist/main.js.map

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion action/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"@actions/github": "9.0.0",
"@aws-sdk/client-s3": "3.975.0",
"bluebird": "3.7.2",
"glob": "13.0.0"
"glob": "13.0.0",
"sharp": "0.34.5"
},
"devDependencies": {
"@types/bluebird": "3.5.42",
Expand Down
94 changes: 94 additions & 0 deletions action/src/image-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { info, getInput, warning } from '@actions/core';
import sharp from 'sharp';
import * as path from 'path';
import { writeFile } from 'fs/promises';

/**
* Get max dimensions from GitHub Action inputs
* Returns undefined if not configured (to skip resizing)
*/
function getMaxDimensions(): { width: number; height: number } | undefined {
const widthInput = getInput('max-image-width');
const heightInput = getInput('max-image-height');

// If neither input is provided, don't resize
if (!widthInput && !heightInput) {
return undefined;
}

const width = Number(widthInput);
const height = Number(heightInput);

if (isNaN(width) || isNaN(height)) {
throw new Error(
`Invalid max dimensions provided (width: ${widthInput}, height: ${heightInput})`
);
}

return {
width,
height
};
}

/**
* Resize image if it exceeds max dimensions
* Returns the file path (original or resized)
*/
export async function resizeImageIfNeeded(
filePath: string,
maxWidth: number,
maxHeight: number
) {
try {
const metadata = await sharp(filePath).metadata();

const originalWidth = metadata.width ?? 0;
const originalHeight = metadata.height ?? 0;

if (originalWidth <= maxWidth && originalHeight <= maxHeight) {
return filePath;
}

// Read, resize, and get buffer in one pipeline
const resizedBuffer = await sharp(filePath)
.resize(maxWidth, maxHeight, {
fit: 'inside', // Fit within bounds, maintain aspect ratio
withoutEnlargement: true // Don't upscale if smaller
})
.png({
compressionLevel: 6, // Balance between quality and file size (0-9)
quality: 90 // PNG quality (0-100)
})
.toBuffer();

// Write buffer directly back to original file path (in-place)
await writeFile(filePath, resizedBuffer);
} catch (error) {
warning(
`Could not resize ${path.basename(filePath)}: ${error}. Using original.`
);
}
}

/**
* Batch resize images in parallel
*/
export async function resizeImages(filePaths: string[]) {
const dimensions = getMaxDimensions();
if (!dimensions) {
info('Image resizing disabled (no max dimensions configured)');
return;
}

const width = dimensions.width;
const height = dimensions.height;

info(
`Starting resize of ${filePaths.length} image(s) with max dimensions ${width}x${height}`
);
await Promise.all(
filePaths.map(filePath => resizeImageIfNeeded(filePath, width, height))
);
info(`Completed resize of ${filePaths.length} image(s)`);
}
5 changes: 5 additions & 0 deletions action/src/s3-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as fs from 'fs';
import { promises as fsPromises } from 'fs';
import { glob } from 'glob';
import { Readable } from 'stream';
import { resizeImages } from './image-utils';

const s3Client = new S3Client();

Expand Down Expand Up @@ -95,6 +96,9 @@ async function uploadLocalDirectory(
`Uploading ${files.length} file(s) from ${localDir} to s3://${bucketName}/${s3Prefix}`
);

const filePaths = files.map(file => path.join(localDir, file));
await resizeImages(filePaths);

await map(files, async file => {
const localFilePath = path.join(localDir, file);
const s3Key = path.join(s3Prefix, file);
Expand Down Expand Up @@ -192,6 +196,7 @@ export const uploadAllImages = async (hash: string) => {
export const uploadBaseImages = async (newFilePaths: string[]) => {
const bucketName = getInput('bucket-name', { required: true });
info(`Uploading ${newFilePaths.length} base image(s)`);
await resizeImages(newFilePaths);
return map(newFilePaths, newFilePath =>
uploadSingleFile(newFilePath, bucketName, buildBaseImagePath(newFilePath))
);
Expand Down
201 changes: 201 additions & 0 deletions action/test/image-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { describe, expect, it, beforeEach, afterEach, mock } from 'bun:test';
import sharp from 'sharp';
import { mkdir, rm } from 'fs/promises';
import { join } from 'path';

// Create a comprehensive mock for @actions/core to avoid conflicts with other tests
mock.module('@actions/core', () => ({
info: mock(() => {}),
getInput: mock(() => ''),
setFailed: mock(() => {}),
getBooleanInput: mock(() => false),
getMultilineInput: mock(() => []),
warning: mock()
}));

const imageUtils = await import('../src/image-utils');
const resizeImageIfNeeded = imageUtils.resizeImageIfNeeded;
const MAX_WIDTH = 1500;
const MAX_HEIGHT = 1500;

const TEST_DIR = join(import.meta.dir, 'temp-test-images');

describe('image-utils', () => {
beforeEach(async () => {
await mkdir(TEST_DIR, { recursive: true });
});

afterEach(async () => {
await rm(TEST_DIR, { recursive: true, force: true });
});

it('should not resize image within size limits', async () => {
const testImagePath = join(TEST_DIR, 'small.png');

// Create a small test image (500x500)
await sharp({
create: {
width: 500,
height: 500,
channels: 4,
background: { r: 255, g: 0, b: 0, alpha: 1 }
}
})
.png()
.toFile(testImagePath);

const originalMetadata = await sharp(testImagePath).metadata();

await resizeImageIfNeeded(testImagePath, MAX_WIDTH, MAX_HEIGHT);

const newMetadata = await sharp(testImagePath).metadata();

expect(newMetadata.width).toBe(originalMetadata.width);
expect(newMetadata.height).toBe(originalMetadata.height);
});

it('should resize image exceeding width limit', async () => {
const testImagePath = join(TEST_DIR, 'wide.png');

// Create a wide test image (3000x1000)
await sharp({
create: {
width: 3000,
height: 1000,
channels: 4,
background: { r: 0, g: 255, b: 0, alpha: 1 }
}
})
.png()
.toFile(testImagePath);

await resizeImageIfNeeded(testImagePath, MAX_WIDTH, MAX_HEIGHT);

const metadata = await sharp(testImagePath).metadata();

expect(metadata.width).toBeLessThanOrEqual(MAX_WIDTH);
expect(metadata.height).toBeLessThanOrEqual(MAX_HEIGHT);

// Check aspect ratio is maintained
const aspectRatio = 3000 / 1000;
const newAspectRatio = (metadata.width ?? 0) / (metadata.height ?? 0);
expect(Math.abs(aspectRatio - newAspectRatio)).toBeLessThan(0.01);
});

it('should resize image exceeding height limit', async () => {
const testImagePath = join(TEST_DIR, 'tall.png');

// Create a tall test image (1000x3000)
await sharp({
create: {
width: 1000,
height: 3000,
channels: 4,
background: { r: 0, g: 0, b: 255, alpha: 1 }
}
})
.png()
.toFile(testImagePath);

await resizeImageIfNeeded(testImagePath, MAX_WIDTH, MAX_HEIGHT);

const metadata = await sharp(testImagePath).metadata();

expect(metadata.width).toBeLessThanOrEqual(MAX_WIDTH);
expect(metadata.height).toBeLessThanOrEqual(MAX_HEIGHT);

// Check aspect ratio is maintained
const aspectRatio = 1000 / 3000;
const newAspectRatio = (metadata.width ?? 0) / (metadata.height ?? 0);
expect(Math.abs(aspectRatio - newAspectRatio)).toBeLessThan(0.01);
});

it('should resize image exceeding both limits', async () => {
const testImagePath = join(TEST_DIR, 'large.png');

// Create a large test image (4000x4000)
await sharp({
create: {
width: 4000,
height: 4000,
channels: 4,
background: { r: 255, g: 255, b: 0, alpha: 1 }
}
})
.png()
.toFile(testImagePath);

await resizeImageIfNeeded(testImagePath, MAX_WIDTH, MAX_HEIGHT);

const metadata = await sharp(testImagePath).metadata();

expect(metadata.width).toBeLessThanOrEqual(MAX_WIDTH);
expect(metadata.height).toBeLessThanOrEqual(MAX_HEIGHT);
expect(metadata.width).toBe(MAX_WIDTH);
expect(metadata.height).toBe(MAX_HEIGHT);
});

it('should handle custom max dimensions', async () => {
const testImagePath = join(TEST_DIR, 'custom.png');
const customMax = 500;

// Create a test image (2000x2000)
await sharp({
create: {
width: 2000,
height: 2000,
channels: 4,
background: { r: 255, g: 0, b: 255, alpha: 1 }
}
})
.png()
.toFile(testImagePath);

await resizeImageIfNeeded(testImagePath, customMax, customMax);

const metadata = await sharp(testImagePath).metadata();

expect(metadata.width).toBeLessThanOrEqual(customMax);
expect(metadata.height).toBeLessThanOrEqual(customMax);
expect(metadata.width).toBe(customMax);
expect(metadata.height).toBe(customMax);
});
});

describe('resizeImages batch function', () => {
beforeEach(async () => {
await mkdir(TEST_DIR, { recursive: true });
});

afterEach(async () => {
await rm(TEST_DIR, { recursive: true, force: true });
});

it('should skip resizing when no max dimensions are configured', async () => {
const testImagePath = join(TEST_DIR, 'large.png');

// Create a large test image (3000x3000)
await sharp({
create: {
width: 3000,
height: 3000,
channels: 4,
background: { r: 255, g: 0, b: 0, alpha: 1 }
}
})
.png()
.toFile(testImagePath);

const originalMetadata = await sharp(testImagePath).metadata();

// Import resizeImages dynamically to use mocked getInput
const { resizeImages } = await import('../src/image-utils');
await resizeImages([testImagePath]);

const newMetadata = await sharp(testImagePath).metadata();

// Image should not be resized (dimensions unchanged)
expect(newMetadata.width).toBe(originalMetadata.width);
expect(newMetadata.height).toBe(originalMetadata.height);
});
});
44 changes: 36 additions & 8 deletions action/test/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,42 @@ const getInputMock = mock();
const getBooleanInputMock = mock();
const getMultilineInputMock = mock();
const setFailedMock = mock();
mock.module('@actions/core', () => ({
info: mock(),
getInput: getInputMock,
getBooleanInput: getBooleanInputMock,
getMultilineInput: getMultilineInputMock,
setFailed: setFailedMock,
warning: mock()
}));
const infoMock = mock();
const warningMock = mock();

// Create a comprehensive mock for @actions/core to handle ES module exports
mock.module('@actions/core', () => {
return {
info: infoMock,
getInput: getInputMock,
getBooleanInput: getBooleanInputMock,
getMultilineInput: getMultilineInputMock,
setFailed: setFailedMock,
warning: warningMock,
// Add other exports that might be imported
debug: mock(),
error: mock(),
setOutput: mock(),
setSecret: mock(),
addPath: mock(),
exportVariable: mock(),
setCommandEcho: mock(),
saveState: mock(),
getState: mock(),
group: mock(),
startGroup: mock(),
endGroup: mock(),
// ES module default export
default: {
info: infoMock,
getInput: getInputMock,
getBooleanInput: getBooleanInputMock,
getMultilineInput: getMultilineInputMock,
setFailed: setFailedMock,
warning: warningMock
}
};
});

const execMock = mock();
mock.module('@actions/exec', () => ({
Expand Down
Loading
Loading