diff --git a/.github/tests/ci-verify-find-pipefail-guard.test.ts b/.github/tests/ci-verify-find-pipefail-guard.test.ts new file mode 100644 index 000000000..542f5f063 --- /dev/null +++ b/.github/tests/ci-verify-find-pipefail-guard.test.ts @@ -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 ...` exits 0 when `` exists but is +// empty, but exits 1 when `` 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(); + + 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, + ); + + expect(unguarded).toStrictEqual([]); +}); diff --git a/.github/tests/crowdin-base-branch-resolver.test.ts b/.github/tests/crowdin-base-branch-resolver.test.ts new file mode 100644 index 000000000..f8ab052a9 --- /dev/null +++ b/.github/tests/crowdin-base-branch-resolver.test.ts @@ -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 '. +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; +} + +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 = {}; + 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'); +}); diff --git a/.github/workflows/ci-verify.yml b/.github/workflows/ci-verify.yml index 257d97bf0..e71b7a998 100644 --- a/.github/workflows/ci-verify.yml +++ b/.github/workflows/ci-verify.yml @@ -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) @@ -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) @@ -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 @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/.github/workflows/i18n-crowdin.yml b/.github/workflows/i18n-crowdin.yml index 0f31c8645..aea54f288 100644 --- a/.github/workflows/i18n-crowdin.yml +++ b/.github/workflows/i18n-crowdin.yml @@ -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 diff --git a/test/cpu-bench.sh b/test/cpu-bench.sh index 79fbff0a0..d086c9b6b 100755 --- a/test/cpu-bench.sh +++ b/test/cpu-bench.sh @@ -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++