Skip to content

Commit 30c7116

Browse files
authored
Merge pull request #131 from useblacksmith/pr-130-fixed
*: add estargz compression support
2 parents 4af38cd + f5c1cdd commit 30c7116

File tree

8 files changed

+259
-32
lines changed

8 files changed

+259
-32
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,37 @@ jobs:
100100
tags: user/app:latest
101101
```
102102
103+
### eStargz compression for faster pulls
104+
105+
```yaml
106+
name: ci
107+
108+
on:
109+
push:
110+
111+
jobs:
112+
docker:
113+
runs-on: blacksmith
114+
steps:
115+
-
116+
name: Checkout
117+
uses: actions/checkout@v4
118+
-
119+
name: Login to Docker Hub
120+
uses: docker/login-action@v3
121+
with:
122+
username: ${{ secrets.DOCKERHUB_USERNAME }}
123+
password: ${{ secrets.DOCKERHUB_TOKEN }}
124+
-
125+
name: Build and push with eStargz
126+
uses: useblacksmith/build-push-action@v1
127+
with:
128+
context: .
129+
push: true
130+
tags: user/app:latest
131+
estargz: true
132+
```
133+
103134
## Examples
104135
105136
* [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:
190221
| `target` | String | Sets the target stage to build |
191222
| `ulimit` | List | [Ulimit](https://docs.docker.com/engine/reference/commandline/buildx_build/#ulimit) options (e.g., `nofile=1024:1024`) |
192223
| `github-token` | String | GitHub Token used to authenticate against a repository for [Git context](#git-context) (default `${{ github.token }}`) |
224+
| `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`) |
193225

194226
### outputs
195227

action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ inputs:
108108
description: "GitHub Token used to authenticate against a repository for Git context"
109109
default: ${{ github.token }}
110110
required: false
111+
estargz:
112+
description: "Enable eStargz compression for faster image pulls (requires push: true)"
113+
required: false
114+
default: 'false'
111115

112116
outputs:
113117
imageid:

dist/index.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package-lock.json

Lines changed: 6 additions & 27 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"packageManager": "[email protected]",
2828
"dependencies": {
2929
"@actions/core": "^1.10.1",
30-
"@buf/blacksmith_vm-agent.connectrpc_es": "^1.6.1-20250304023716-e8d233d92eac.2",
30+
"@buf/blacksmith_vm-agent.connectrpc_es": "^1.6.1-20251002224722-c44b45f26c5e.2",
3131
"@connectrpc/connect": "^1.6.1",
3232
"@connectrpc/connect-node": "^1.6.1",
3333
"@docker/actions-toolkit": "0.37.1",

src/__tests__/estargz.test.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import * as core from '@actions/core';
2+
import {getArgs, Inputs} from '../context';
3+
import {Toolkit} from '@docker/actions-toolkit/lib/toolkit';
4+
5+
jest.mock('@actions/core');
6+
7+
// Mock the Toolkit.
8+
jest.mock('@docker/actions-toolkit/lib/toolkit');
9+
10+
describe('eStargz compression', () => {
11+
let mockToolkit: jest.Mocked<Toolkit>;
12+
let baseInputs: Inputs;
13+
14+
beforeEach(() => {
15+
jest.clearAllMocks();
16+
17+
// Create a mock toolkit with all necessary methods.
18+
mockToolkit = {
19+
buildx: {
20+
versionSatisfies: jest.fn(),
21+
getCommand: jest.fn(),
22+
printVersion: jest.fn(),
23+
isAvailable: jest.fn()
24+
},
25+
buildxBuild: {
26+
getImageIDFilePath: jest.fn().mockReturnValue('/tmp/iidfile'),
27+
getMetadataFilePath: jest.fn().mockReturnValue('/tmp/metadata'),
28+
resolveImageID: jest.fn(),
29+
resolveMetadata: jest.fn(),
30+
resolveDigest: jest.fn(),
31+
resolveWarnings: jest.fn(),
32+
resolveRef: jest.fn()
33+
},
34+
builder: {
35+
inspect: jest.fn().mockResolvedValue({
36+
name: 'default',
37+
driver: 'docker-container',
38+
nodes: []
39+
})
40+
},
41+
buildkit: {
42+
versionSatisfies: jest.fn().mockResolvedValue(false)
43+
}
44+
} as any;
45+
46+
// Base inputs for testing.
47+
baseInputs = {
48+
'add-hosts': [],
49+
allow: [],
50+
annotations: [],
51+
attests: [],
52+
'build-args': [],
53+
'build-contexts': [],
54+
builder: '',
55+
'cache-from': [],
56+
'cache-to': [],
57+
'cgroup-parent': '',
58+
context: '.',
59+
file: '',
60+
labels: [],
61+
load: false,
62+
network: '',
63+
'no-cache': false,
64+
'no-cache-filters': [],
65+
outputs: [],
66+
platforms: [],
67+
provenance: '',
68+
pull: false,
69+
push: false,
70+
sbom: '',
71+
secrets: [],
72+
'secret-envs': [],
73+
'secret-files': [],
74+
'shm-size': '',
75+
ssh: [],
76+
tags: ['user/app:latest'],
77+
target: '',
78+
ulimit: [],
79+
'github-token': '',
80+
estargz: false
81+
};
82+
});
83+
84+
test('should not add estargz parameters when estargz is false', async () => {
85+
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockResolvedValue(true);
86+
87+
const inputs = {...baseInputs, push: true, estargz: false};
88+
const args = await getArgs(inputs, mockToolkit);
89+
90+
expect(args.join(' ')).not.toContain('compression=estargz');
91+
});
92+
93+
test('should not add estargz parameters when push is false', async () => {
94+
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockResolvedValue(true);
95+
96+
const inputs = {...baseInputs, push: false, estargz: true};
97+
const args = await getArgs(inputs, mockToolkit);
98+
99+
expect(args.join(' ')).not.toContain('compression=estargz');
100+
expect(core.warning).toHaveBeenCalledWith("eStargz compression requires push: true; the input 'estargz' is ignored.");
101+
});
102+
103+
test('should not add estargz parameters when buildx version is < 0.10.0', async () => {
104+
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockImplementation(async (version: string) => {
105+
return version === '>=0.6.0'; // Only 0.6.0 check passes, not 0.10.0.
106+
});
107+
108+
const inputs = {...baseInputs, push: true, estargz: true};
109+
const args = await getArgs(inputs, mockToolkit);
110+
111+
expect(args.join(' ')).not.toContain('compression=estargz');
112+
expect(core.warning).toHaveBeenCalledWith("eStargz compression requires buildx >= 0.10.0; the input 'estargz' is ignored.");
113+
});
114+
115+
test('should add estargz output when estargz is true, push is true, and buildx >= 0.10.0', async () => {
116+
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockResolvedValue(true);
117+
118+
const inputs = {...baseInputs, push: true, estargz: true};
119+
const args = await getArgs(inputs, mockToolkit);
120+
121+
expect(args).toContain('--output');
122+
const outputIndex = args.indexOf('--output');
123+
expect(args[outputIndex + 1]).toBe('type=registry,compression=estargz,force-compression=true,oci-mediatypes=true');
124+
});
125+
126+
test('should modify existing registry output with estargz parameters', async () => {
127+
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockResolvedValue(true);
128+
129+
const inputs = {
130+
...baseInputs,
131+
push: true,
132+
estargz: true,
133+
outputs: ['type=registry,dest=output.txt']
134+
};
135+
const args = await getArgs(inputs, mockToolkit);
136+
137+
expect(args).toContain('--output');
138+
const outputIndex = args.indexOf('--output');
139+
expect(args[outputIndex + 1]).toBe('type=registry,dest=output.txt,compression=estargz,force-compression=true,oci-mediatypes=true');
140+
});
141+
142+
test('should not modify non-registry outputs with estargz parameters', async () => {
143+
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockResolvedValue(true);
144+
145+
const inputs = {
146+
...baseInputs,
147+
push: true,
148+
estargz: true,
149+
outputs: ['type=docker']
150+
};
151+
const args = await getArgs(inputs, mockToolkit);
152+
153+
expect(args).toContain('--output');
154+
const outputIndex = args.indexOf('--output');
155+
expect(args[outputIndex + 1]).toBe('type=docker');
156+
});
157+
158+
test('should handle multiple outputs correctly', async () => {
159+
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockResolvedValue(true);
160+
161+
const inputs = {
162+
...baseInputs,
163+
push: true,
164+
estargz: true,
165+
outputs: ['type=registry', 'type=docker']
166+
};
167+
const args = await getArgs(inputs, mockToolkit);
168+
169+
const argsStr = args.join(' ');
170+
expect(argsStr).toContain('type=registry,compression=estargz,force-compression=true,oci-mediatypes=true');
171+
expect(argsStr).toContain('type=docker');
172+
});
173+
174+
test('should work with existing registry output without additional params', async () => {
175+
(mockToolkit.buildx.versionSatisfies as jest.Mock).mockResolvedValue(true);
176+
177+
const inputs = {
178+
...baseInputs,
179+
push: true,
180+
estargz: true,
181+
outputs: ['type=registry']
182+
};
183+
const args = await getArgs(inputs, mockToolkit);
184+
185+
expect(args).toContain('--output');
186+
const outputIndex = args.indexOf('--output');
187+
expect(args[outputIndex + 1]).toBe('type=registry,compression=estargz,force-compression=true,oci-mediatypes=true');
188+
});
189+
});

src/context.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export interface Inputs {
5757
target: string;
5858
ulimit: string[];
5959
'github-token': string;
60+
estargz: boolean;
6061
}
6162

6263
export async function getInputs(): Promise<Inputs> {
@@ -93,7 +94,8 @@ export async function getInputs(): Promise<Inputs> {
9394
tags: Util.getInputList('tags'),
9495
target: core.getInput('target'),
9596
ulimit: Util.getInputList('ulimit', {ignoreComma: true}),
96-
'github-token': core.getInput('github-token')
97+
'github-token': core.getInput('github-token'),
98+
estargz: core.getBooleanInput('estargz')
9799
};
98100
}
99101

@@ -207,9 +209,30 @@ async function getBuildArgs(inputs: Inputs, context: string, toolkit: Toolkit):
207209
await Util.asyncForEach(inputs['no-cache-filters'], async noCacheFilter => {
208210
args.push('--no-cache-filter', noCacheFilter);
209211
});
212+
213+
// Check estargz requirements BEFORE modifying outputs.
214+
const useEstargz = inputs.estargz && inputs.push && (await toolkit.buildx.versionSatisfies('>=0.10.0'));
215+
216+
if (inputs.estargz) {
217+
if (!(await toolkit.buildx.versionSatisfies('>=0.10.0'))) {
218+
core.warning("eStargz compression requires buildx >= 0.10.0; the input 'estargz' is ignored.");
219+
} else if (!inputs.push) {
220+
core.warning("eStargz compression requires push: true; the input 'estargz' is ignored.");
221+
}
222+
}
223+
210224
await Util.asyncForEach(inputs.outputs, async output => {
211-
args.push('--output', output);
225+
if (useEstargz && (output.startsWith('type=registry') || output === 'type=registry')) {
226+
const estargzOutput = `${output},compression=estargz,force-compression=true,oci-mediatypes=true`;
227+
args.push('--output', estargzOutput);
228+
} else {
229+
args.push('--output', output);
230+
}
212231
});
232+
233+
if (useEstargz && inputs.outputs.length === 0) {
234+
args.push('--output', 'type=registry,compression=estargz,force-compression=true,oci-mediatypes=true');
235+
}
213236
if (inputs.platforms.length > 0) {
214237
args.push('--platform', inputs.platforms.join(','));
215238
}

0 commit comments

Comments
 (0)