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
132 changes: 132 additions & 0 deletions .github/tests/ci-verify-find-pipefail-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { fileURLToPath } from 'node:url';

import { loadWorkflow as loadWorkflowFrom } from './workflow-test-utils';

// GitHub runs every `run:` step without an explicit `shell:` under
// `bash -eo pipefail`. `find <dir> ...` exits 0 when `<dir>` exists but is
// empty, but exits 1 when `<dir>` itself is MISSING (verified on this
// machine). ci-verify.yml's load-test steps create their artifact directory
// only on a successful run (scripts/run-load-test.sh), so a failed load test
// leaves the directory absent. Piping that failing `find` straight into
// `sort | head | cut` inside `report="$(...)"` aborts the step under
// pipefail before the deliberate `if [ -z "${report}" ]` empty-report
// handling in scripts/summarize-load-test-report.sh and
// scripts/check-load-test-correctness.sh ever runs -- the same "pipefail
// dead-fallback" class as the i18n-crowdin.yml grep bug covered by
// crowdin-base-branch-resolver.test.ts. The fix wraps the `find` in
// `{ find ... || true; }` so a missing directory yields an empty result
// instead of aborting the step.
//
// Actually executing these steps needs docker + real Artillery artifacts, so
// this is a static assertion over the workflow's `run:` text rather than a
// behavioral test. That's a deliberate tradeoff: it can't prove the guarded
// form behaves correctly end to end, but it can prove -- and keep proving --
// that no `find` piped into a variable assignment ships unguarded.
//
// The sites are discovered dynamically by walking every job/step and
// regex-matching the find-into-assignment shape, rather than pinned to the
// current 8 line numbers or step names. That's the point: it also catches a
// brand new unguarded `find` added later, not just a revert of today's fix.

const workflowPath = fileURLToPath(new URL('../workflows/ci-verify.yml', import.meta.url));
const loadWorkflow = loadWorkflowFrom.bind(undefined, workflowPath);

// Matches a whole-line shell assignment that captures a command
// substitution's output, e.g.:
// report="$({ find ... || true; } | sort -rn | head -n1 | cut -d' ' -f2-)"
// current_report="$(find ... | sort -rn | head -n1 | cut -d' ' -f2-)"
const FIND_ASSIGNMENT_LINE = /^\s*\w+="\$\((.*)\)"\s*$/;

// A guarded `find` invocation is wrapped so a non-zero exit (missing
// directory) is swallowed before it can hit the pipe: `{ find ... || true; }`.
const GUARDED_FIND_PREFIX = /^\{\s*find\b[\s\S]*?\|\|\s*true;\s*\}/;

interface FindAssignmentSite {
jobId: string;
stepName: string;
inner: string;
line: string;
}

// Reconstructs logical shell lines from a `run:` block by folding backslash
// continuations, so a site reformatted across `\` line breaks is still
// discovered by the single-line FIND_ASSIGNMENT_LINE regex below. A line
// ending in a trailing `\` (optionally followed by trailing whitespace)
// continues onto the next physical line.
function joinContinuations(run: string): string[] {
const logicalLines: string[] = [];
let pending = '';

for (const rawLine of run.split('\n')) {
const continued = /\\\s*$/.test(rawLine);
const chunk = continued ? rawLine.replace(/\\\s*$/, '') : rawLine;
pending += (pending ? ' ' : '') + chunk.trim();
Comment on lines +51 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate file and inspect relevant sections"
fd -a 'ci-verify-find-pipefail-guard.test.ts' . || true
if [ -f .github/tests/ci-verify-find-pipefail-guard.test.ts ]; then
  wc -l .github/tests/ci-verify-find-pipefail-guard.test.ts
  sed -n '1,180p' .github/tests/ci-verify-find-pipefail-guard.test.ts | cat -n
fi

echo
echo "Search related function/usages"
rg -n "joinContinuations|FIND_ASSIGNMENT_LINE|pipefail|continuation" .github . --glob '!node_modules' --glob '!dist' --glob '!build' || true

Repository: CodesWhat/drydock

Length of output: 15733


🌐 Web query:

POSIX shell command line continuation backslash immediately before newline trailing whitespace behavior

💡 Result:

In the POSIX shell command language, when a backslash immediately precedes a newline character, it is interpreted as line continuation [1][2][3]. The shell removes both the backslash and the newline character from the input before the tokenization process begins [1][4]. Regarding trailing whitespace, the POSIX standard explicitly states that the escaped newline is removed entirely and is not replaced by any whitespace [1][2][5]. Consequently, if there is trailing whitespace (such as spaces or tabs) on the same line after the backslash but before the newline, that whitespace remains in the input stream and is not part of the line continuation sequence. Because the backslash only acts as a line continuation character when it is immediately followed by a newline, any intervening characters—including whitespace—prevent the backslash from being treated as a line continuation [1][2]. In such cases, the backslash is treated as a literal character (unless quoted), and the newline remains a newline character, effectively ending the command line at that point [1][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import re

def current(join_):
    logical_lines = []
    pending = ''
    for line in join_.split('\n'):
        continued = bool(re.search(r'\\\s*$', line))
        chunk = re.sub(r'\\\s*$', '', line) if continued else line
        pending += (' ' if pending else '') + chunk.strip()
        if not continued:
            logical_lines.append(pending)
            pending = ''
    if pending:
        logical_lines.append(pending)
    return logical_lines

def posix_continuation(join_):
    logical_lines = []
    pending = ''
    for line in join_.split('\n'):
        continued = line.endswith('\\')
        chunk = line[:-1] if continued else line
        pending += (' ' if pending else '') + chunk.strip()
        if not continued:
            logical_lines.append(pending)
            pending = ''
    if pending:
        logical_lines.append(pending)
    return logical_lines

cases = {
    'immediate_backslash_newline': 'foo\\\nbar',
    'backslash_whitespace_before_newline': 'foo \\  \nbar',
    'quoted_backslash_before_newline': "'foo\\\nbar'",
    'single_line_no_continuation': 'report="$(find . | sort)"',
    'realistic_spacing_without_real_continuation': 'report="$(find \\\n".")"',
}
for name, case in cases.items():
    print(f'{name}:')
    print('  current:', current(case))
    print('  current_regex:', bool(re.search(r'\\\s*$', case.split("\n")[0])))
    print('  line_reconstructed_for_scan:', current(case)[0]) if current(case) else None
    print('  shell_continues:', case.split('\n')[0].endswith('\\'))
    print('  shell_logical_line:', posix_continuation(case)[0])
PY

Repository: CodesWhat/drydock

Length of output: 1118


🌐 Web query:

Bash quoted backslash-newline continuation behavior bash manual

💡 Result:

In Bash, the behavior of a backslash-newline pair depends on whether the backslash is quoted. 1. Unquoted backslash-newline: When a backslash is not quoted, a backslash-newline pair is treated as a line continuation [1][2][3]. This causes the backslash and the newline character to be removed from the input stream and effectively ignored, allowing a single logical line to span multiple physical lines [1][2][3]. 2. Double-quoted backslash-newline: Within double quotes, the backslash retains its special meaning only when followed by specific characters, one of which is a newline [1][3]. Consequently, a backslash followed by a newline inside double quotes is also treated as a line continuation and removed [1][3]. 3. Single-quoted backslash-newline: Inside single quotes, all characters—including backslashes and newlines—are treated literally [1]. Therefore, a backslash-newline pair inside single quotes does not function as a line continuation; the backslash is preserved and the newline remains as part of the string [1]. 4. Dollar-single-quoted ($'string') backslash-newline: Within this quoting mechanism, backslash escape sequences are interpreted according to ANSI C standards [1][3]. A backslash followed by a newline in this context is typically used to represent escape sequences or literal characters, but it does not function as a standard shell line continuation mechanism [1][3].

Citations:


🌐 Web query:

POSIX shell double quoted backslash newline continuation inside double quotes

💡 Result:

According to the POSIX Shell Command Language standard, the behavior of a backslash followed by a newline character (a "line continuation") within double quotes depends on whether that backslash is acting as an escape character [1][2]. Within double quotes, the shell interprets a backslash as an escape character only when it is followed by one of the following special characters: $, `, ", , or a newline [3][4]. When a backslash is immediately followed by a newline character inside double quotes: 1. The shell interprets this sequence as a line continuation [1][2]. 2. The backslash and the newline character are removed entirely from the input [1][2]. 3. Because the characters are removed and not replaced by whitespace, the sequence does not act as a token separator [1][2]. This mechanism effectively allows a single logical line to be split across multiple physical lines in the source code while being treated as a continuous string by the shell [1][2]. It is important to note that if you wish to include a literal backslash followed by a newline in your output (i.e., you do not want the shell to consume them), you must escape the backslash itself (e.g., \ followed by a newline). In this case, the first backslash escapes the second, preventing it from being interpreted as an escape character for the newline, thereby preserving both the backslash and the newline in the resulting string [1][2].

Citations:


Preserve real pipeline boundary semantics.

Backslash-newline is a single valid continuation; trailing whitespace after the backslash is not handled by the shell, and shell continuation removes the line break, it does not replace it with a space. This static scan should only join the actual shell-continued command so it does not create fake find pipelines.

function joinContinuations(run: string): string[] {
  const logicalLines: string[] = [];
  let pending = '';

  for (const rawLine of run.split('\n')) {
    const continued = rawLine.endsWith('\\') && !rawLine.endsWith('\\\\');
    const chunk = continued ? rawLine.slice(0, -1) : rawLine;
    pending += (pending ? ' ' : '') + chunk.trim();

    if (!continued) {
      logicalLines.push(pending);
      pending = '';
    }
  }

  if (pending) logicalLines.push(pending);
  return logicalLines;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/tests/ci-verify-find-pipefail-guard.test.ts around lines 51 - 63,
The joinContinuations function incorrectly treats backslashes followed by
whitespace as shell continuations and inserts a space where shell continuation
removes the newline. Restrict continuation detection to a single backslash that
is the final character of rawLine, remove only that backslash, and preserve the
existing logical-line flushing behavior for non-continued lines and trailing
pending content.


if (!continued) {
logicalLines.push(pending);
pending = '';
}
}

if (pending) logicalLines.push(pending);

return logicalLines;
}

function findFindAssignmentSites(): FindAssignmentSite[] {
const workflow = loadWorkflow();
const sites: FindAssignmentSite[] = [];

for (const [jobId, job] of Object.entries(workflow.jobs ?? {})) {
for (const step of job.steps ?? []) {
if (!step.run) continue;

for (const line of joinContinuations(step.run)) {
const match = line.match(FIND_ASSIGNMENT_LINE);
if (!match) continue;

const inner = match[1].trim();
if (!/\bfind\b/.test(inner)) continue;

sites.push({
jobId,
stepName: step.name ?? '(unnamed step)',
inner,
line: line.trim(),
});
}
}
}

return sites;
}

// Pinned to the current count of guarded find-into-assignment sites in
// ci-verify.yml. This must be bumped deliberately, in either direction, when
// a site is added or removed -- a plain `> 0` sanity check would let the
// sweep silently drop sites (e.g. a regex that stops matching a reformatted
// site) without failing.
const EXPECTED_FIND_ASSIGNMENT_SITE_COUNT = 8;

test('ci-verify.yml has find-into-variable assignments to guard against pipefail', () => {
// Sanity check on the sweep itself: pinned to the exact expected count
// rather than `> 0` so a drop in EITHER direction fails loudly -- including
// a site silently falling out of the sweep (e.g. reformatted across a
// backslash continuation that the regex no longer matches).
expect(findFindAssignmentSites().length).toBe(EXPECTED_FIND_ASSIGNMENT_SITE_COUNT);
});

test('every find-into-variable pipeline in ci-verify.yml is guarded against a missing-directory exit', () => {
const unguarded = findFindAssignmentSites().filter(
(site) =>
!GUARDED_FIND_PREFIX.test(site.inner) ||
// GUARDED_FIND_PREFIX only anchors the *first* stage, so a pipeline like
// `{ find a || true; } | find b ...` would otherwise pass with its second
// find still able to abort under pipefail. Rather than try to parse each
// stage, require exactly one find per assignment: every site here has one,
// and a second one should fail this test so someone has to look at it.
(site.inner.match(/\bfind\b/g) ?? []).length !== 1,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

expect(unguarded).toStrictEqual([]);
});
137 changes: 137 additions & 0 deletions .github/tests/crowdin-base-branch-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { execFileSync } from 'node:child_process';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { loadWorkflow } from './workflow-test-utils';

// This test extracts and EXECUTES the real `run:` block from the workflow
// (rather than a copy pasted into the test) so it can't drift from what
// actually ships, and so it fails outright if someone reverts the step to the
// broken pre-2ad6c5c9 form: a no-match `grep` under `pipefail` used to exit 1
// and abort the assignment before the `if [ -z "${base}" ]` fallback could
// ever run.
const workflowPath = fileURLToPath(new URL('../workflows/i18n-crowdin.yml', import.meta.url));

const stubGitScript = `#!/usr/bin/env bash
# Stub 'git' for the resolver script under test. Only implements the one
# subcommand the resolver calls: 'ls-remote --heads origin <pattern>'.
if [ "\${1:-}" = "ls-remote" ]; then
if [ -n "\${FAKE_GIT_LS_REMOTE_EXIT:-}" ] && [ "\${FAKE_GIT_LS_REMOTE_EXIT}" != "0" ]; then
echo "fatal: unable to access 'origin': stubbed network failure" >&2
exit "\${FAKE_GIT_LS_REMOTE_EXIT}"
fi
printf '%s' "\${FAKE_GIT_LS_REMOTE_OUTPUT:-}"
exit 0
fi
echo "stub git: unsupported invocation: $*" >&2
exit 127
`;

function loadResolverRunBlock(): string {
const workflow = loadWorkflow(workflowPath);
const step = workflow.jobs?.sync?.steps?.find((candidate) => candidate.id === 'base');

if (!step?.run) {
throw new Error(
"Expected i18n-crowdin.yml's sync job to include a step with id 'base' and a run block",
);
}

return step.run;
}

interface ResolverResult {
status: number;
stdout: string;
stderr: string;
output: Record<string, string>;
}

function runResolver(options: {
lsRemoteOutput?: string;
lsRemoteExit?: number;
defaultBranch?: string;
}): ResolverResult {
const workdir = mkdtempSync(join(tmpdir(), 'crowdin-base-resolver-'));
try {
const stubDir = join(workdir, 'bin');
mkdirSync(stubDir);
writeFileSync(join(stubDir, 'git'), stubGitScript, { mode: 0o755 });
chmodSync(join(stubDir, 'git'), 0o755);

const scriptPath = join(workdir, 'resolve-base.sh');
writeFileSync(scriptPath, loadResolverRunBlock());

const outputPath = join(workdir, 'github_output');
writeFileSync(outputPath, '');

const env: NodeJS.ProcessEnv = {
PATH: `${stubDir}:${process.env.PATH ?? ''}`,
DEFAULT_BRANCH: options.defaultBranch ?? 'main',
GITHUB_OUTPUT: outputPath,
FAKE_GIT_LS_REMOTE_OUTPUT: options.lsRemoteOutput ?? '',
};
if (options.lsRemoteExit !== undefined) {
env.FAKE_GIT_LS_REMOTE_EXIT = String(options.lsRemoteExit);
}

let status = 0;
let stdout = '';
let stderr = '';
try {
stdout = execFileSync('bash', [scriptPath], { env, encoding: 'utf8' });
} catch (error) {
const execError = error as { status?: number; stdout?: string; stderr?: string };
status = execError.status ?? 1;
stdout = execError.stdout ?? '';
stderr = execError.stderr ?? '';
}

const output: Record<string, string> = {};
for (const line of readFileSync(outputPath, 'utf8').split('\n')) {
const eq = line.indexOf('=');
if (eq === -1) continue;
output[line.slice(0, eq)] = line.slice(eq + 1);
}

return { status, stdout, stderr, output };
} finally {
rmSync(workdir, { recursive: true, force: true });
}
}

test('falls back to the default branch when origin has no dev/vX.Y branches', () => {
const result = runResolver({
lsRemoteOutput: 'deadbeef\trefs/heads/main\ncafef00d\trefs/heads/feature/something\n',
defaultBranch: 'main',
});

expect(result.status).toBe(0);
expect(result.output.name).toBe('main');
});

test('picks the highest dev/vX.Y branch numerically, not lexically', () => {
const result = runResolver({
lsRemoteOutput: [
'aaaaaaa\trefs/heads/dev/v1.6',
'bbbbbbb\trefs/heads/dev/v1.9',
'ccccccc\trefs/heads/dev/v1.10',
'ddddddd\trefs/heads/not-a-dev-branch',
].join('\n'),
});

expect(result.status).toBe(0);
expect(result.output.name).toBe('dev/v1.10');
});

test('a genuinely failing ls-remote propagates and does not silently fall back to main', () => {
const result = runResolver({ lsRemoteExit: 128, defaultBranch: 'main' });

expect(result.status).not.toBe(0);
// The step must die before it ever reaches the fallback assignment — no
// output should have been written at all.
expect(result.output.name).toBeUndefined();
expect(result.stdout).not.toContain('main');
});
16 changes: 8 additions & 8 deletions .github/workflows/ci-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1249,7 +1249,7 @@ jobs:
- name: Summarize load test metrics (ci)
if: always()
run: |
report="$(find artifacts/load-test/ci -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -n1 | cut -d' ' -f2-)"
report="$({ find artifacts/load-test/ci -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null || true; } | sort -rn | head -n1 | cut -d' ' -f2-)"
./scripts/summarize-load-test-report.sh "$report" "Load Test (CI)" "test/load-test-baselines/ci.json"

- name: Correctness check (ci, enforced)
Expand All @@ -1261,7 +1261,7 @@ jobs:
DD_LOAD_TEST_MIN_429: '0'
DD_LOAD_TEST_MAX_429: '0'
run: |
report="$(find artifacts/load-test/ci -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -n1 | cut -d' ' -f2-)"
report="$({ find artifacts/load-test/ci -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null || true; } | sort -rn | head -n1 | cut -d' ' -f2-)"
./scripts/check-load-test-correctness.sh "$report" "Load Test Correctness (CI)"

- name: Resolve committed load test baseline (ci)
Expand Down Expand Up @@ -1299,7 +1299,7 @@ jobs:
exit 1
fi

current_report="$(find artifacts/load-test/ci -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -n1 | cut -d' ' -f2-)"
current_report="$({ find artifacts/load-test/ci -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null || true; } | sort -rn | head -n1 | cut -d' ' -f2-)"
if [ -z "${current_report}" ]; then
echo "::error::Current CI report not found; cannot run regression gate."
exit 1
Expand Down Expand Up @@ -1372,7 +1372,7 @@ jobs:
- name: Summarize load test metrics (behavior)
if: always()
run: |
report="$(find artifacts/load-test/behavior -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -n1 | cut -d' ' -f2-)"
report="$({ find artifacts/load-test/behavior -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null || true; } | sort -rn | head -n1 | cut -d' ' -f2-)"
./scripts/summarize-load-test-report.sh "$report" "Load Test (Behavior)" "test/load-test-baselines/behavior.json"

- name: Correctness check (behavior, advisory)
Expand All @@ -1384,7 +1384,7 @@ jobs:
DD_LOAD_TEST_MIN_429: '0'
DD_LOAD_TEST_MAX_429: '0'
run: |
report="$(find artifacts/load-test/behavior -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -n1 | cut -d' ' -f2-)"
report="$({ find artifacts/load-test/behavior -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null || true; } | sort -rn | head -n1 | cut -d' ' -f2-)"
./scripts/check-load-test-correctness.sh "$report" "Load Test Correctness (Behavior)"

- name: Resolve committed load test baseline (behavior)
Expand Down Expand Up @@ -1417,7 +1417,7 @@ jobs:
run: |
set -euo pipefail

current_report="$(find artifacts/load-test/behavior -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -n1 | cut -d' ' -f2-)"
current_report="$({ find artifacts/load-test/behavior -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null || true; } | sort -rn | head -n1 | cut -d' ' -f2-)"
./scripts/check-load-test-regression.sh "${current_report}" "${BASELINE_REPORT}"

- name: Upload load test artifact (behavior)
Expand All @@ -1442,7 +1442,7 @@ jobs:
- name: Summarize load test metrics (stress)
if: always()
run: |
report="$(find artifacts/load-test/stress -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -n1 | cut -d' ' -f2-)"
report="$({ find artifacts/load-test/stress -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null || true; } | sort -rn | head -n1 | cut -d' ' -f2-)"
./scripts/summarize-load-test-report.sh "$report" "Load Test (Stress)"

- name: Correctness check (stress, advisory)
Expand All @@ -1454,7 +1454,7 @@ jobs:
DD_LOAD_TEST_MIN_429: '0'
DD_LOAD_TEST_MAX_429: '0'
run: |
report="$(find artifacts/load-test/stress -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -n1 | cut -d' ' -f2-)"
report="$({ find artifacts/load-test/stress -maxdepth 1 -name '*.json' -printf '%T@ %p\n' 2>/dev/null || true; } | sort -rn | head -n1 | cut -d' ' -f2-)"
./scripts/check-load-test-correctness.sh "$report" "Load Test Correctness (Stress)"

- name: Upload load test artifact (stress)
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/i18n-crowdin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,15 @@ jobs:
# dev/v1.9. Falls back to the default branch during the window between
# a GA and the next dev branch being cut, so translations are never
# stranded with nowhere to land.
#
# The grep is guarded: with `pipefail` a no-match grep exits 1 and
# aborts the assignment, which killed the fallback below before it
# could ever run (2026-07-24, when the sync PR merge briefly deleted
# dev/v1.6). A failing ls-remote still propagates, so a network or
# auth error fails loudly instead of silently retargeting main.
base="$(git ls-remote --heads origin 'refs/heads/dev/v*' \
| sed 's#.*refs/heads/##' \
| grep -E '^dev/v[0-9]+\.[0-9]+$' \
| { grep -E '^dev/v[0-9]+\.[0-9]+$' || true; } \
| sort -t/ -k2 -V \
| tail -1)"
if [ -z "${base}" ]; then
Expand Down
2 changes: 1 addition & 1 deletion test/cpu-bench.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ printf "%-20s %10s %10s %10s %10s\n" "Container" "Avg CPU%" "Min CPU%" "Max CPU%
printf "%-20s %10s %10s %10s %10s\n" "--------------------" "----------" "----------" "----------" "----------"

for c in $CONTAINERS; do
RESULT=$(grep "^$c " "$TMPFILE" | awk '
RESULT=$({ grep "^$c " "$TMPFILE" || true; } | awk '
BEGIN { sum=0; count=0; min=9999; max=0 }
{
sum += $2; count++
Expand Down
Loading