diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 643ce30d677..9a48cc3554b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -455,6 +455,23 @@ jobs: if-no-files-found: 'error' overwrite: true + # prepare:package's guards (unpacked size budget, forbidden literals, + # missing bundle artifacts) had no gate before the publish job, so the + # first thing that tripped one was the Dockerfile's builder stage in + # integration_docker — a ~20 minute wait for a one-line error, and + # before the build_sandbox.js output fix, one that printed nothing. + # Running the same steps here reports it in minutes instead. Kept after + # the upload so the packed artifact stays exactly what the build steps + # above produced. + - name: 'Verify Prepared Package' + run: |- + npm run bundle + test -f dist/review-sources.sha256 || { + echo "::error::review source stamp missing — see the copy_bundle_assets warning above" + exit 1 + } + npm run prepare:package + quality_typecheck: name: 'Quality Checks (Typecheck)' runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen-hk4-host"]'') || fromJSON(''["ubuntu-latest"]'') }}' diff --git a/scripts/build_sandbox.js b/scripts/build_sandbox.js index fb1f08fc40b..f31a3f429c0 100644 --- a/scripts/build_sandbox.js +++ b/scripts/build_sandbox.js @@ -110,7 +110,36 @@ if (!argv.s) { execSync('npm pack', { stdio: 'ignore', cwd: distDir }); } -const buildStdout = process.env.VERBOSE ? 'inherit' : 'ignore'; +// The image build is the longest step this script runs, and its output used to +// be discarded unless VERBOSE was set: a failure surfaced as an execSync stack +// trace with `stdout: null`, so the line that actually failed inside the +// Dockerfile (a packaging guard, an apt failure, an OOM kill) was gone and the +// only way to see it was to re-run the whole build by hand. CI always streams +// it; interactive runs stay quiet but keep the output so a failure can print +// it. +const streamBuildOutput = Boolean(process.env.VERBOSE || process.env.CI); +const buildStdio = streamBuildOutput ? 'inherit' : ['ignore', 'pipe', 'pipe']; +// A non-verbose build still writes megabytes of layer progress. execSync kills +// the child once maxBuffer is exceeded, so this has to clear a real build's +// output by a wide margin — otherwise capturing it would itself fail the build. +const BUILD_OUTPUT_MAX_BUFFER = 128 * 1024 * 1024; +const BUILD_OUTPUT_TAIL_LINES = 200; + +function printCapturedBuildOutput(error) { + const captured = [error?.stdout, error?.stderr] + .map((stream) => stream?.toString() ?? '') + .join('') + .split('\n'); + const tail = captured.slice(-BUILD_OUTPUT_TAIL_LINES).join('\n').trim(); + if (!tail) return; + console.error( + `\n--- last ${BUILD_OUTPUT_TAIL_LINES} lines of ${sandboxCommand} build output ---`, + ); + console.error(tail); + console.error( + '--- end of build output (set VERBOSE=true to stream it) ---\n', + ); +} // Determine the appropriate shell based on OS const isWindows = os.platform() === 'win32'; @@ -148,7 +177,11 @@ function buildImage(imageName, dockerfile) { `${sandboxCommand} build ${buildCommandArgs} ${ process.env.BUILD_SANDBOX_FLAGS || '' } --build-arg CLI_VERSION_ARG=${npmPackageVersion} -f "${dockerfile}" -t "${finalImageName}" .`, - { stdio: buildStdout, shell: shellToUse }, + { + stdio: buildStdio, + shell: shellToUse, + maxBuffer: BUILD_OUTPUT_MAX_BUFFER, + }, ); console.log(`built ${finalImageName}`); @@ -166,6 +199,12 @@ function buildImage(imageName, dockerfile) { } writeFileSync(argv.outputFile, finalImageName); } + } catch (error) { + if (!streamBuildOutput && (error?.stdout || error?.stderr)) { + printCapturedBuildOutput(error); + error.message = `${sandboxCommand} build failed — output printed above`; + } + throw error; } finally { // If we created a temp file, delete it now. if (tempAuthFile) { diff --git a/scripts/tests/build-sandbox-output.test.js b/scripts/tests/build-sandbox-output.test.js new file mode 100644 index 00000000000..29ac43cb81e --- /dev/null +++ b/scripts/tests/build-sandbox-output.test.js @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execFileSync, spawn } from 'node:child_process'; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); +const scriptPath = path.join(repoRoot, 'scripts', 'build_sandbox.js'); + +// The marker stands in for whatever the Dockerfile prints before it fails — +// the packaging size guard, an apt error, a killed process. BuildKit puts the +// actionable failure on stderr, so the fixture does too. +const FAILURE_MARKER = 'FAKE-DOCKER-BUILD-FAILURE-MARKER'; + +let fakeBinDir; + +/** + * Installs a `docker` on PATH that echoes a marker and exits non-zero for + * `build`, and succeeds for everything else (`image prune`, version probes). + */ +function installFakeDocker() { + const fakeDocker = path.join(fakeBinDir, 'docker'); + writeFileSync( + fakeDocker, + [ + '#!/bin/sh', + 'if [ "$1" = "build" ]; then', + ` awk 'BEGIN{for(i=0;i<40000;i++) print "PAD-LINE-" i}'`, + ` echo "${FAILURE_MARKER}" >&2`, + ' [ "$FAKE_DOCKER_PAUSE" = "true" ] && sleep 1', + ' exit 1', + 'fi', + 'exit 0', + '', + ].join('\n'), + ); + chmodSync(fakeDocker, 0o755); +} + +function buildSandboxEnv(env) { + return { + ...process.env, + PATH: `${fakeBinDir}${path.delimiter}${process.env.PATH}`, + QWEN_SANDBOX: 'docker', + VERBOSE: '', + CI: '', + ...env, + }; +} + +/** Runs build_sandbox.js with the fake docker, never throwing on failure. */ +function runBuildSandbox(env) { + try { + const stdout = execFileSync( + process.execPath, + [scriptPath, '-s', '--no-prune'], + { + cwd: repoRoot, + encoding: 'utf8', + env: buildSandboxEnv(env), + }, + ); + return { status: 0, stdout, stderr: '' }; + } catch (err) { + return { + status: err.status ?? 1, + stdout: err.stdout?.toString() ?? '', + stderr: err.stderr?.toString() ?? '', + }; + } +} + +describe.skipIf(os.platform() === 'win32')( + 'build_sandbox.js image build output', + () => { + beforeEach(() => { + fakeBinDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-fake-docker-')); + installFakeDocker(); + }); + + afterEach(() => { + rmSync(fakeBinDir, { recursive: true, force: true }); + }); + + it('prints the captured build output when a quiet build fails', () => { + const { status, stdout, stderr } = runBuildSandbox({}); + const combined = `${stdout}${stderr}`; + const blockStart = combined.indexOf('--- last 200 lines of'); + const blockEnd = combined.indexOf('--- end of build output'); + const block = combined.slice(blockStart, blockEnd); + + expect(status).not.toBe(0); + expect(blockStart).not.toBe(-1); + expect(blockEnd).toBeGreaterThan(blockStart); + expect(block).toContain(FAILURE_MARKER); + expect(block).not.toContain('PAD-LINE-0\n'); + expect(combined.split(FAILURE_MARKER)).toHaveLength(2); + }); + + it('streams the build output under CI without waiting for a failure', async () => { + const child = spawn(process.execPath, [scriptPath, '-s', '--no-prune'], { + cwd: repoRoot, + env: buildSandboxEnv({ CI: 'true', FAKE_DOCKER_PAUSE: 'true' }), + }); + let combined = ''; + let markOutputSeen; + const outputSeen = new Promise((resolve) => { + markOutputSeen = resolve; + }); + for (const stream of [child.stdout, child.stderr]) { + stream.on('data', (chunk) => { + combined += chunk.toString(); + if (combined.includes(FAILURE_MARKER)) markOutputSeen(); + }); + } + const closed = new Promise((resolve) => { + child.once('close', (status) => resolve(status ?? 1)); + }); + + expect(await Promise.race([outputSeen, closed])).toBeUndefined(); + const status = await closed; + expect(status).not.toBe(0); + expect(combined).toContain(FAILURE_MARKER); + // Streamed output is not re-printed from a capture buffer. + expect(combined).not.toContain('end of build output'); + }); + + it('streams the build output under VERBOSE', () => { + const { status, stdout, stderr } = runBuildSandbox({ VERBOSE: 'true' }); + const combined = `${stdout}${stderr}`; + + expect(status).not.toBe(0); + expect(combined).toContain(FAILURE_MARKER); + expect(combined).not.toContain('end of build output'); + }); + }, +); diff --git a/scripts/tests/package-scripts.test.js b/scripts/tests/package-scripts.test.js index 509255ade10..43fc5f576e5 100644 --- a/scripts/tests/package-scripts.test.js +++ b/scripts/tests/package-scripts.test.js @@ -444,6 +444,12 @@ describe('package scripts', () => { buildJob, 'Check Serve Fast Path Bundle', ); + const packStep = getWorkflowStep(buildJob, 'Pack Build Outputs'); + const uploadStep = getWorkflowStep(buildJob, 'Upload Build Outputs'); + const verifyPackageStep = getWorkflowStep( + buildJob, + 'Verify Prepared Package', + ); const workspaceTestStep = getWorkflowStep( workspaceTestJob, 'Run Workspace Tests', @@ -458,6 +464,15 @@ describe('package scripts', () => { expect(buildJob.indexOf(serveFastPathStep)).toBeLessThan( buildJob.indexOf(buildStep), ); + expect(buildJob.indexOf(uploadStep)).toBeGreaterThan( + buildJob.indexOf(packStep), + ); + expect(buildJob.indexOf(verifyPackageStep)).toBeGreaterThan( + buildJob.indexOf(uploadStep), + ); + expect(verifyPackageStep).toContain('npm run bundle'); + expect(verifyPackageStep).toContain('dist/review-sources.sha256'); + expect(verifyPackageStep).toContain('npm run prepare:package'); expect(workspaceTestStep).toContain('npm run test:release:workspaces'); expect(workspaceTestStep).not.toContain('npm run test:ci'); expect(scriptsTestStep).toContain('npm run test:scripts');