-
-
Notifications
You must be signed in to change notification settings - Fork 10
🐛 fix(ci): make pipefail dead-fallback handlers actually reachable #599
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
2ad6c5c
🐛 fix(ci): make the Crowdin base-branch fallback actually reachable
scttbnsn f4968eb
✅ test(ci): cover the Crowdin base-branch fallback
scttbnsn 50930e2
🐛 fix(ci): guard load-test find pipelines against a missing artifact dir
scttbnsn 3d651b1
✅ test(ci): reject later unguarded find stages too
scttbnsn da0de06
✅ test(ci): stop the find sweep silently losing sites
scttbnsn b9fb496
🐛 fix(test): guard the cpu-bench results grep
scttbnsn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
|
|
||
| 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, | ||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| expect(unguarded).toStrictEqual([]); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
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:
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
findpipelines.🤖 Prompt for AI Agents