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
17 changes: 17 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
yiliang114 marked this conversation as resolved.
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
Comment thread
yiliang114 marked this conversation as resolved.

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"]'') }}'
Expand Down
43 changes: 41 additions & 2 deletions scripts/build_sandbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`);

Expand All @@ -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;
Comment thread
yiliang114 marked this conversation as resolved.
} finally {
// If we created a temp file, delete it now.
if (tempAuthFile) {
Expand Down
148 changes: 148 additions & 0 deletions scripts/tests/build-sandbox-output.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
},
);
15 changes: 15 additions & 0 deletions scripts/tests/package-scripts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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');
Expand Down
Loading