Merge pull request #131 from useblacksmith/pr-130-fixed

*: add estargz compression support
This commit is contained in:
Aditya Maru 2025-10-05 19:10:48 -04:00 committed by GitHub
commit 30c71162f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 259 additions and 32 deletions

View File

@ -100,6 +100,37 @@ jobs:
tags: user/app:latest
```
### eStargz compression for faster pulls
```yaml
name: ci
on:
push:
jobs:
docker:
runs-on: blacksmith
steps:
-
name: Checkout
uses: actions/checkout@v4
-
name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push with eStargz
uses: useblacksmith/build-push-action@v1
with:
context: .
push: true
tags: user/app:latest
estargz: true
```
## Examples
* [Multi-platform image](https://docs.docker.com/build/ci/github-actions/multi-platform/)
@ -190,6 +221,7 @@ The following inputs can be used as `step.with` keys:
| `target` | String | Sets the target stage to build |
| `ulimit` | List | [Ulimit](https://docs.docker.com/engine/reference/commandline/buildx_build/#ulimit) options (e.g., `nofile=1024:1024`) |
| `github-token` | String | GitHub Token used to authenticate against a repository for [Git context](#git-context) (default `${{ github.token }}`) |
| `estargz` | Bool | Enable [eStargz compression](https://github.com/containerd/stargz-snapshotter/blob/main/docs/estargz.md) for faster image pulls (requires `push: true`) (default `false`) |
### outputs

View File

@ -108,6 +108,10 @@ inputs:
description: "GitHub Token used to authenticate against a repository for Git context"
default: ${{ github.token }}
required: false
estargz:
description: "Enable eStargz compression for faster image pulls (requires push: true)"
required: false
default: 'false'
outputs:
imageid:

2
dist/index.js generated vendored

File diff suppressed because one or more lines are too long

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

33
package-lock.json generated
View File

@ -8,7 +8,7 @@
"license": "Apache-2.0",
"dependencies": {
"@actions/core": "^1.10.1",
"@buf/blacksmith_vm-agent.connectrpc_es": "^1.6.1-20250304023716-e8d233d92eac.2",
"@buf/blacksmith_vm-agent.connectrpc_es": "^1.6.1-20251002224722-c44b45f26c5e.2",
"@connectrpc/connect": "^1.6.1",
"@connectrpc/connect-node": "^1.6.1",
"@docker/actions-toolkit": "0.37.1",
@ -2379,38 +2379,17 @@
"dev": true
},
"node_modules/@buf/blacksmith_vm-agent.bufbuild_es": {
"version": "1.10.0-20250304023716-e8d233d92eac.1",
"resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.bufbuild_es/-/blacksmith_vm-agent.bufbuild_es-1.10.0-20250304023716-e8d233d92eac.1.tgz",
"dependencies": {
"@buf/googleapis_googleapis.bufbuild_es": "1.10.0-20250203201857-83c0f6c19b2f.1"
},
"version": "1.10.0-20251002224722-c44b45f26c5e.1",
"resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.bufbuild_es/-/blacksmith_vm-agent.bufbuild_es-1.10.0-20251002224722-c44b45f26c5e.1.tgz",
"peerDependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@buf/blacksmith_vm-agent.connectrpc_es": {
"version": "1.6.1-20250304023716-e8d233d92eac.2",
"resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.connectrpc_es/-/blacksmith_vm-agent.connectrpc_es-1.6.1-20250304023716-e8d233d92eac.2.tgz",
"version": "1.6.1-20251002224722-c44b45f26c5e.2",
"resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.connectrpc_es/-/blacksmith_vm-agent.connectrpc_es-1.6.1-20251002224722-c44b45f26c5e.2.tgz",
"dependencies": {
"@buf/blacksmith_vm-agent.bufbuild_es": "1.10.0-20250304023716-e8d233d92eac.1",
"@buf/googleapis_googleapis.connectrpc_es": "1.6.1-20250203201857-83c0f6c19b2f.2"
},
"peerDependencies": {
"@connectrpc/connect": "^1.6.1"
}
},
"node_modules/@buf/googleapis_googleapis.bufbuild_es": {
"version": "1.10.0-20250203201857-83c0f6c19b2f.1",
"resolved": "https://buf.build/gen/npm/v1/@buf/googleapis_googleapis.bufbuild_es/-/googleapis_googleapis.bufbuild_es-1.10.0-20250203201857-83c0f6c19b2f.1.tgz",
"peerDependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@buf/googleapis_googleapis.connectrpc_es": {
"version": "1.6.1-20250203201857-83c0f6c19b2f.2",
"resolved": "https://buf.build/gen/npm/v1/@buf/googleapis_googleapis.connectrpc_es/-/googleapis_googleapis.connectrpc_es-1.6.1-20250203201857-83c0f6c19b2f.2.tgz",
"dependencies": {
"@buf/googleapis_googleapis.bufbuild_es": "1.10.0-20250203201857-83c0f6c19b2f.1"
"@buf/blacksmith_vm-agent.bufbuild_es": "1.10.0-20251002224722-c44b45f26c5e.1"
},
"peerDependencies": {
"@connectrpc/connect": "^1.6.1"

View File

@ -27,7 +27,7 @@
"packageManager": "yarn@3.6.3",
"dependencies": {
"@actions/core": "^1.10.1",
"@buf/blacksmith_vm-agent.connectrpc_es": "^1.6.1-20250304023716-e8d233d92eac.2",
"@buf/blacksmith_vm-agent.connectrpc_es": "^1.6.1-20251002224722-c44b45f26c5e.2",
"@connectrpc/connect": "^1.6.1",
"@connectrpc/connect-node": "^1.6.1",
"@docker/actions-toolkit": "0.37.1",

View File

@ -0,0 +1,189 @@
import * as core from '@actions/core';
import {getArgs, Inputs} from '../context';
import {Toolkit} from '@docker/actions-toolkit/lib/toolkit';
jest.mock('@actions/core');
// Mock the Toolkit.
jest.mock('@docker/actions-toolkit/lib/toolkit');
describe('eStargz compression', () => {
let mockToolkit: jest.Mocked<Toolkit>;
let baseInputs: Inputs;
beforeEach(() => {
jest.clearAllMocks();
// Create a mock toolkit with all necessary methods.
mockToolkit = {
buildx: {
versionSatisfies: jest.fn(),
getCommand: jest.fn(),
printVersion: jest.fn(),
isAvailable: jest.fn()
},
buildxBuild: {
getImageIDFilePath: jest.fn().mockReturnValue('/tmp/iidfile'),
getMetadataFilePath: jest.fn().mockReturnValue('/tmp/metadata'),
resolveImageID: jest.fn(),
resolveMetadata: jest.fn(),
resolveDigest: jest.fn(),
resolveWarnings: jest.fn(),
resolveRef: jest.fn()
},
builder: {
inspect: jest.fn().mockResolvedValue({
name: 'default',
driver: 'docker-container',
nodes: []
})
},
buildkit: {
versionSatisfies: jest.fn().mockResolvedValue(false)
}
} as any;
// Base inputs for testing.
baseInputs = {
'add-hosts': [],
allow: [],
annotations: [],
attests: [],
'build-args': [],
'build-contexts': [],
builder: '',
'cache-from': [],
'cache-to': [],
'cgroup-parent': '',
context: '.',
file: '',
labels: [],
load: false,
network: '',
'no-cache': false,
'no-cache-filters': [],
outputs: [],
platforms: [],
provenance: '',
pull: false,
push: false,
sbom: '',
secrets: [],
'secret-envs': [],
'secret-files': [],
'shm-size': '',
ssh: [],
tags: ['user/app:latest'],
target: '',
ulimit: [],
'github-token': '',
estargz: false
};
});
test('should not add estargz parameters when estargz is false', async () => {
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockResolvedValue(true);
const inputs = {...baseInputs, push: true, estargz: false};
const args = await getArgs(inputs, mockToolkit);
expect(args.join(' ')).not.toContain('compression=estargz');
});
test('should not add estargz parameters when push is false', async () => {
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockResolvedValue(true);
const inputs = {...baseInputs, push: false, estargz: true};
const args = await getArgs(inputs, mockToolkit);
expect(args.join(' ')).not.toContain('compression=estargz');
expect(core.warning).toHaveBeenCalledWith("eStargz compression requires push: true; the input 'estargz' is ignored.");
});
test('should not add estargz parameters when buildx version is < 0.10.0', async () => {
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockImplementation(async (version: string) => {
return version === '>=0.6.0'; // Only 0.6.0 check passes, not 0.10.0.
});
const inputs = {...baseInputs, push: true, estargz: true};
const args = await getArgs(inputs, mockToolkit);
expect(args.join(' ')).not.toContain('compression=estargz');
expect(core.warning).toHaveBeenCalledWith("eStargz compression requires buildx >= 0.10.0; the input 'estargz' is ignored.");
});
test('should add estargz output when estargz is true, push is true, and buildx >= 0.10.0', async () => {
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockResolvedValue(true);
const inputs = {...baseInputs, push: true, estargz: true};
const args = await getArgs(inputs, mockToolkit);
expect(args).toContain('--output');
const outputIndex = args.indexOf('--output');
expect(args[outputIndex + 1]).toBe('type=registry,compression=estargz,force-compression=true,oci-mediatypes=true');
});
test('should modify existing registry output with estargz parameters', async () => {
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockResolvedValue(true);
const inputs = {
...baseInputs,
push: true,
estargz: true,
outputs: ['type=registry,dest=output.txt']
};
const args = await getArgs(inputs, mockToolkit);
expect(args).toContain('--output');
const outputIndex = args.indexOf('--output');
expect(args[outputIndex + 1]).toBe('type=registry,dest=output.txt,compression=estargz,force-compression=true,oci-mediatypes=true');
});
test('should not modify non-registry outputs with estargz parameters', async () => {
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockResolvedValue(true);
const inputs = {
...baseInputs,
push: true,
estargz: true,
outputs: ['type=docker']
};
const args = await getArgs(inputs, mockToolkit);
expect(args).toContain('--output');
const outputIndex = args.indexOf('--output');
expect(args[outputIndex + 1]).toBe('type=docker');
});
test('should handle multiple outputs correctly', async () => {
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockResolvedValue(true);
const inputs = {
...baseInputs,
push: true,
estargz: true,
outputs: ['type=registry', 'type=docker']
};
const args = await getArgs(inputs, mockToolkit);
const argsStr = args.join(' ');
expect(argsStr).toContain('type=registry,compression=estargz,force-compression=true,oci-mediatypes=true');
expect(argsStr).toContain('type=docker');
});
test('should work with existing registry output without additional params', async () => {
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockResolvedValue(true);
const inputs = {
...baseInputs,
push: true,
estargz: true,
outputs: ['type=registry']
};
const args = await getArgs(inputs, mockToolkit);
expect(args).toContain('--output');
const outputIndex = args.indexOf('--output');
expect(args[outputIndex + 1]).toBe('type=registry,compression=estargz,force-compression=true,oci-mediatypes=true');
});
});

View File

@ -57,6 +57,7 @@ export interface Inputs {
target: string;
ulimit: string[];
'github-token': string;
estargz: boolean;
}
export async function getInputs(): Promise<Inputs> {
@ -93,7 +94,8 @@ export async function getInputs(): Promise<Inputs> {
tags: Util.getInputList('tags'),
target: core.getInput('target'),
ulimit: Util.getInputList('ulimit', {ignoreComma: true}),
'github-token': core.getInput('github-token')
'github-token': core.getInput('github-token'),
estargz: core.getBooleanInput('estargz')
};
}
@ -207,9 +209,30 @@ async function getBuildArgs(inputs: Inputs, context: string, toolkit: Toolkit):
await Util.asyncForEach(inputs['no-cache-filters'], async noCacheFilter => {
args.push('--no-cache-filter', noCacheFilter);
});
// Check estargz requirements BEFORE modifying outputs.
const useEstargz = inputs.estargz && inputs.push && (await toolkit.buildx.versionSatisfies('>=0.10.0'));
if (inputs.estargz) {
if (!(await toolkit.buildx.versionSatisfies('>=0.10.0'))) {
core.warning("eStargz compression requires buildx >= 0.10.0; the input 'estargz' is ignored.");
} else if (!inputs.push) {
core.warning("eStargz compression requires push: true; the input 'estargz' is ignored.");
}
}
await Util.asyncForEach(inputs.outputs, async output => {
args.push('--output', output);
if (useEstargz && (output.startsWith('type=registry') || output === 'type=registry')) {
const estargzOutput = `${output},compression=estargz,force-compression=true,oci-mediatypes=true`;
args.push('--output', estargzOutput);
} else {
args.push('--output', output);
}
});
if (useEstargz && inputs.outputs.length === 0) {
args.push('--output', 'type=registry,compression=estargz,force-compression=true,oci-mediatypes=true');
}
if (inputs.platforms.length > 0) {
args.push('--platform', inputs.platforms.join(','));
}