Skip to content

Commit 12dd4ef

Browse files
authored
fix: pass artifact paths to rsync via --files-from (#1825)
1 parent e7bcd27 commit 12dd4ef

4 files changed

Lines changed: 66 additions & 6 deletions

File tree

src/job.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1424,16 +1424,18 @@ If you know what you're doing and would like to suppress this warning, use one o
14241424
let time, endTime;
14251425
let cpCmd = "shopt -s globstar nullglob dotglob\n";
14261426
cpCmd += `mkdir -p ${artifactsPath}/${safeJobName}\n`;
1427-
cpCmd += "rsync --exclude '.gitlab-ci-local/**' -Ra ";
1427+
cpCmd += "_gcl_files_tmp=\\$(mktemp)\n";
1428+
for (const artifactPath of this.artifacts?.paths ?? []) {
1429+
const expandedPath = Utils.expandText(artifactPath, expanded).replace(`${expanded.CI_PROJECT_DIR}/`, "");
1430+
cpCmd += `for _gcl_f in ./${expandedPath}; do printf '%s\\n' "\\$_gcl_f"; done >> \\$_gcl_files_tmp\n`;
1431+
}
1432+
cpCmd += "rsync --exclude '.gitlab-ci-local/**' -rRa ";
14281433
for (const artifactExcludePath of this.artifacts?.exclude ?? []) {
14291434
const expandedPath = Utils.expandText(artifactExcludePath, expanded).replace(`${expanded.CI_PROJECT_DIR}/`, "");
14301435
cpCmd += `--exclude '${expandedPath}' `;
14311436
}
1432-
for (const artifactPath of this.artifacts?.paths ?? []) {
1433-
const expandedPath = Utils.expandText(artifactPath, expanded).replace(`${expanded.CI_PROJECT_DIR}/`, "");
1434-
cpCmd += `./${expandedPath} `;
1435-
}
1436-
cpCmd += `${artifactsPath}/${safeJobName}/. || true\n`;
1437+
cpCmd += `--files-from=\\$_gcl_files_tmp . ${artifactsPath}/${safeJobName}/. || true\n`;
1438+
cpCmd += "rm -f \\$_gcl_files_tmp\n";
14371439
const reportDotenv = Utils.expandText(this.artifacts.reports?.dotenv ?? null, expanded);
14381440
const reportDotenvs: string[] | null = (typeof reportDotenv === "string") ? // normalize to string[] for easier handling
14391441
[reportDotenv] :
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/artifact_*
2+
/output_dir/
3+
/.gitlab-ci-local-many-paths/
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
# Creates 9000 files whose names are 255 characters long.
3+
# With the old approach (paths passed as rsync arguments), the total argument
4+
# size (~2.3 MB) exceeded ARG_MAX (~2 MB) and rsync failed silently.
5+
# With the new approach (--files-from + bash for loop), there is no limit on
6+
# the number of files.
7+
produce-artifacts:
8+
stage: build
9+
script:
10+
- |
11+
python3 -c "
12+
import os
13+
padding = 'x' * 240
14+
for i in range(9000):
15+
name = f'artifact_{i:05d}_{padding}'
16+
fd = os.open(name, os.O_CREAT | os.O_WRONLY, 0o644)
17+
os.close(fd)
18+
"
19+
- mkdir -p output_dir && echo "hello" > output_dir/result.txt
20+
artifacts:
21+
paths:
22+
- artifact_*
23+
- output_dir/
24+
25+
consume-artifacts:
26+
stage: test
27+
script:
28+
- count=$(find . -maxdepth 1 -name 'artifact_*' | wc -l)
29+
- if [ "$count" -ne 9000 ]; then echo "FAIL expected 9000 artifacts, got $count"; exit 1; fi
30+
- if [ ! -f output_dir/result.txt ]; then echo "FAIL output_dir/result.txt not found"; exit 1; fi
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {WriteStreamsMock} from "../../../src/write-streams.js";
2+
import {handler} from "../../../src/handler.js";
3+
import {initSpawnSpy} from "../../mocks/utils.mock.js";
4+
import {WhenStatics} from "../../mocks/when-statics.js";
5+
6+
beforeAll(() => {
7+
initSpawnSpy(WhenStatics.all);
8+
});
9+
10+
// Validates that --files-from works correctly when the number of artifact files
11+
// would exceed ARG_MAX if their paths were passed directly as rsync arguments
12+
// (9000 files × ~257 bytes ≈ 2.3 MB > ARG_MAX ~2 MB).
13+
test.concurrent("artifacts-many-paths --shell-isolation 9000 artifacts via --files-from", async () => {
14+
const writeStreams = new WriteStreamsMock();
15+
await handler({
16+
cwd: "tests/test-cases/artifacts-many-paths",
17+
shellIsolation: true,
18+
stateDir: ".gitlab-ci-local-many-paths",
19+
}, writeStreams);
20+
21+
// Ensures artifacts were actually exported (not silently swallowed by || true).
22+
expect(writeStreams.stdoutLines.join("\n")).toMatch(/produce-artifacts.*exported artifacts/);
23+
// Ensures consume-artifacts found all 9000 expected files.
24+
expect(writeStreams.stderrLines.join("\n")).not.toMatch(/FAIL/);
25+
}, 120_000);

0 commit comments

Comments
 (0)