mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
278 lines
12 KiB
JavaScript
278 lines
12 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { parse } from 'yaml';
|
|
import { AUDIO_TARGETS } from '../../.github/scripts/audio-prebuilds.mjs';
|
|
|
|
const workflow = (name) =>
|
|
parse(readFileSync(`.github/workflows/${name}.yml`, 'utf8'));
|
|
const releaseYaml = workflow('release');
|
|
const audioYaml = workflow('audio-capture-prebuilds');
|
|
const step = (job, name) =>
|
|
releaseYaml.jobs[job].steps.find((entry) => entry.name === name);
|
|
|
|
describe('audio prebuild reuse wiring', () => {
|
|
it('builds every target required by payload validation', () => {
|
|
const build = audioYaml.jobs.build;
|
|
const matrix = build.strategy.matrix.include;
|
|
expect(matrix.find(({ os }) => os.startsWith('macos'))).toEqual({
|
|
os: 'macos-15',
|
|
runner: 'macos-15',
|
|
arch: 'arm64',
|
|
artifact_suffix: 'arm64+x64',
|
|
});
|
|
const targets = matrix.flatMap(({ os, arch }) => {
|
|
if (os.startsWith('macos')) return [`darwin-${arch}`, 'darwin-x64'];
|
|
return [`${os.startsWith('windows') ? 'win32' : 'linux'}-${arch}`];
|
|
});
|
|
expect(targets.sort()).toEqual([...AUDIO_TARGETS].sort());
|
|
expect(
|
|
build.steps.find((entry) => entry.name === 'Build prebuild').run,
|
|
).toContain('npm run prebuildify -- --target 22.22.3 --arch x64');
|
|
});
|
|
|
|
it('only publishes a reuse ID after a trial download and payload validation', () => {
|
|
// The ID comes from the acceptance step, not the lookup: a candidate that
|
|
// cannot be downloaded or does not validate must leave the output empty so
|
|
// the fresh build still runs.
|
|
for (const [output, source] of Object.entries({
|
|
audio_prebuild_key: '${{ steps.audio_key.outputs.cache_key }}',
|
|
audio_artifact_id: '${{ steps.audio_reuse.outputs.artifact_id }}',
|
|
audio_run_id: '${{ steps.audio_reuse.outputs.run_id }}',
|
|
}))
|
|
expect(releaseYaml.jobs.prepare.outputs[output]).toBe(source);
|
|
expect(releaseYaml.jobs.prepare.permissions.actions).toBe('read');
|
|
|
|
const lookup = step('prepare', 'Find reusable audio prebuilds');
|
|
const fingerprint = step('prepare', 'Fingerprint audio build inputs');
|
|
const download = step('prepare', 'Try reusable audio prebuilds');
|
|
const accept = step('prepare', 'Accept complete audio prebuilds');
|
|
expect(fingerprint.id).toBe('audio_key');
|
|
expect(lookup.env.AUDIO_KEY).toBe(
|
|
'${{ steps.audio_key.outputs.cache_key }}',
|
|
);
|
|
const steps = releaseYaml.jobs.prepare.steps;
|
|
for (const [before, after] of [
|
|
[fingerprint, lookup],
|
|
[lookup, download],
|
|
[download, accept],
|
|
])
|
|
expect(steps.indexOf(before)).toBeLessThan(steps.indexOf(after));
|
|
// Failure before acceptance must leave the fresh-build path available.
|
|
for (const entry of [fingerprint, lookup, download, accept])
|
|
expect(entry['continue-on-error']).toBe(true);
|
|
expect(fingerprint.run).toContain('echo "directory=$directory"');
|
|
expect(fingerprint.run.indexOf('echo "directory=$directory"')).toBeLessThan(
|
|
fingerprint.run.indexOf('audio-prebuilds.mjs key'),
|
|
);
|
|
expect(download.if).toBe(
|
|
"${{ steps.audio_candidate.outputs.artifact_id != '' }}",
|
|
);
|
|
// outcome, not conclusion: continue-on-error rewrites conclusion to success.
|
|
expect(accept.if).toBe("${{ steps.audio_download.outcome == 'success' }}");
|
|
expect(accept.run.trim().split('\n')).toEqual([
|
|
'node .release-workflow/.github/scripts/audio-prebuilds.mjs validate "$AUDIO_DIRECTORY"',
|
|
'echo "artifact_id=$AUDIO_ARTIFACT_ID" >> "$GITHUB_OUTPUT"',
|
|
'echo "run_id=$AUDIO_RUN_ID" >> "$GITHUB_OUTPUT"',
|
|
]);
|
|
|
|
// The trial bytes are always discarded, including when a step failed.
|
|
const cleanup = step('prepare', 'Remove trial audio download');
|
|
expect(download.with.path).toBe('${{ steps.audio_key.outputs.directory }}');
|
|
for (const entry of [accept, cleanup])
|
|
expect(entry.env.AUDIO_DIRECTORY).toBe(download.with.path);
|
|
expect(cleanup.if).toContain('always()');
|
|
expect(cleanup.run).toBe('rm -rf -- "$AUDIO_DIRECTORY"');
|
|
|
|
// The fingerprint has to see the producing workflow, so it must be
|
|
// checked out alongside the helper.
|
|
const checkout = releaseYaml.jobs.prepare.steps.find((entry) =>
|
|
entry.with?.['sparse-checkout']?.includes('/.github/scripts'),
|
|
);
|
|
expect(checkout.with['sparse-checkout']).toContain(
|
|
'/.github/workflows/audio-capture-prebuilds.yml',
|
|
);
|
|
});
|
|
|
|
it('skips the native matrix on a hit and still gates the publisher', () => {
|
|
const native = releaseYaml.jobs.audio_capture_prebuilds;
|
|
expect(native.if).toContain(
|
|
"needs.prepare.outputs.audio_artifact_id == ''",
|
|
);
|
|
expect(native.with.ref).toBe('${{ needs.prepare.outputs.release_sha }}');
|
|
expect(native.with.cache_key).toBe(
|
|
'${{ needs.prepare.outputs.audio_prebuild_key }}',
|
|
);
|
|
|
|
// A skipped matrix is only acceptable when a reuse ID was actually
|
|
// selected, otherwise the publisher would ship without prebuilds.
|
|
const gate = releaseYaml.jobs.publish.if;
|
|
expect(gate).toContain("needs.audio_capture_prebuilds.result == 'success'");
|
|
expect(gate).toContain(
|
|
"needs.audio_capture_prebuilds.result == 'skipped' && needs.prepare.outputs.audio_artifact_id != ''",
|
|
);
|
|
});
|
|
|
|
it.each(['build_release', 'publish'])(
|
|
'rechecks the reused source and validates either path in %s',
|
|
(job) => {
|
|
expect(releaseYaml.jobs[job].permissions.actions).toBe('read');
|
|
// prepare's decision is not trusted blindly: each consumer recomputes the
|
|
// fingerprint from its own checkout and re-verifies the source run.
|
|
const verify = step(job, 'Verify reusable audio source');
|
|
expect(verify.if).toBe(
|
|
"${{ needs.prepare.outputs.audio_artifact_id != '' }}",
|
|
);
|
|
expect(verify['continue-on-error']).toBeUndefined();
|
|
expect(verify.with.script).toContain('audioPrebuildKey');
|
|
expect(verify.with.script).toContain('verifyAudioArtifact');
|
|
expect(verify.with.script).toContain(
|
|
'await verifyAudioArtifact(github, context.repo, artifact, key);',
|
|
);
|
|
expect(verify.with.script).not.toContain('AUDIO_SELECTION_MARGIN_MS');
|
|
|
|
const reused = step(job, 'Download reusable audio prebuilds');
|
|
const fresh = step(job, 'Download audio capture prebuilds');
|
|
expect(reused.if).toBe(
|
|
"${{ needs.prepare.outputs.audio_artifact_id != '' }}",
|
|
);
|
|
expect(fresh.if).toContain(
|
|
"needs.prepare.outputs.audio_artifact_id == ''",
|
|
);
|
|
expect(reused.with['artifact-ids']).toBe(
|
|
'${{ needs.prepare.outputs.audio_artifact_id }}',
|
|
);
|
|
expect(reused.with['run-id']).toBe(
|
|
'${{ needs.prepare.outputs.audio_run_id }}',
|
|
);
|
|
// Exactly one of the two runs, and both land in the same directory.
|
|
expect(reused.with.path).toBe(fresh.with.path);
|
|
const validateStep = step(job, 'Validate complete audio prebuilds');
|
|
expect(validateStep['continue-on-error']).toBeUndefined();
|
|
expect(validateStep.if).toBe(
|
|
"${{ github.repository == 'QwenLM/qwen-code' }}",
|
|
);
|
|
expect(validateStep.run).toBe(
|
|
`node .release-workflow/.github/scripts/audio-prebuilds.mjs validate ${reused.with.path}`,
|
|
);
|
|
|
|
// Whichever path ran, the payload is validated before the build consumes it.
|
|
const names = releaseYaml.jobs[job].steps.map((entry) => entry.name);
|
|
const validate = names.indexOf('Validate complete audio prebuilds');
|
|
expect(validate).toBeGreaterThan(names.indexOf(reused.name));
|
|
expect(validate).toBeGreaterThan(names.indexOf(fresh.name));
|
|
expect(validate).toBeLessThan(
|
|
names.indexOf('Build Bundle and Prepare Package'),
|
|
);
|
|
},
|
|
);
|
|
|
|
it('retains a fingerprint-named artifact for later releases', () => {
|
|
const collect = audioYaml.jobs.collect;
|
|
const retain = collect.steps.find(
|
|
(entry) => entry.name === 'Retain reusable audio prebuilds',
|
|
);
|
|
// Without the keyed upload nothing can ever be reused; the fixed-name
|
|
// artifact stays for the same-run consumer.
|
|
expect(retain.if).toBe("${{ inputs.cache_key != '' }}");
|
|
expect(retain['continue-on-error']).toBe(true);
|
|
expect(retain.with.path).toBe('packages/audio-capture/prebuilds/');
|
|
expect(retain.with.name).toBe(
|
|
'audio-capture-prebuilds-${{ inputs.cache_key }}',
|
|
);
|
|
expect(retain.with['if-no-files-found']).toBe('error');
|
|
expect(retain.with.overwrite).toBe(true);
|
|
expect(
|
|
collect.steps.find((entry) => entry.name === 'Upload combined prebuilds')
|
|
.with.name,
|
|
).toBe('audio-capture-prebuilds');
|
|
expect(
|
|
collect.steps.find((entry) => entry.name === 'Upload combined prebuilds')
|
|
.with.overwrite,
|
|
).toBe(true);
|
|
expect(
|
|
collect.steps.find((entry) => entry.name === 'Upload combined prebuilds')[
|
|
'continue-on-error'
|
|
],
|
|
).toBeUndefined();
|
|
// The producer builds the ref the release pinned, not the branch tip.
|
|
expect(
|
|
audioYaml.jobs.build.steps.find((entry) =>
|
|
entry.uses?.startsWith('actions/checkout@'),
|
|
).with.ref,
|
|
).toBe('${{ inputs.ref || github.sha }}');
|
|
});
|
|
});
|
|
|
|
describe('prebuild collector hygiene', () => {
|
|
it('wipes the workspace through the shared preflight before merging', () => {
|
|
// download-artifact's merge-multiple merges into existing paths, so a
|
|
// stale prebuilds/ tree on a persistent host would leak into the artifact.
|
|
// The shared preflight wipes with path containment; the subtree rm it
|
|
// replaced followed a leftover symlinked parent out of the workspace.
|
|
const steps = audioYaml.jobs.collect.steps;
|
|
expect(steps[0].uses).toBe('$/.github/actions/prepare-release-runner');
|
|
expect(steps[1].name).toBe('Merge per-platform prebuilds');
|
|
for (const entry of steps)
|
|
expect(entry.run ?? '').not.toContain('rm -rf -- "${GITHUB_WORKSPACE}');
|
|
});
|
|
|
|
it('keeps the release-only label architecture-free', () => {
|
|
// The release pool includes ARM64 runners. An "x64"
|
|
// component does not fall back to hosted — it never matches, and the job
|
|
// queues until it times out. Resolve the expression rather than
|
|
// substring-matching it, which would pass with the pin present.
|
|
const route = (enabled, repository = 'QwenLM/qwen-code') =>
|
|
Function(
|
|
'github',
|
|
'vars',
|
|
'fromJSON',
|
|
`return (${audioYaml.jobs.collect['runs-on'].slice(3, -2)})`,
|
|
)(
|
|
{ repository },
|
|
{ ECS_RELEASE_HOST_RUNNER_ENABLED: enabled },
|
|
JSON.parse,
|
|
);
|
|
expect(route('true')).toEqual([
|
|
'self-hosted',
|
|
'linux',
|
|
'ecs-qwen-release-host',
|
|
]);
|
|
expect([route(''), route('false'), route('true', 'fork/repo')]).toEqual([
|
|
['ubuntu-latest'],
|
|
['ubuntu-latest'],
|
|
['ubuntu-latest'],
|
|
]);
|
|
});
|
|
|
|
it('has a Node contract on both runner kinds before it runs node', () => {
|
|
// The validator shells out to `node`; the collector previously inherited
|
|
// Node from the hosted image by accident and asserted nothing.
|
|
const steps = audioYaml.jobs.collect.steps;
|
|
const node = steps.filter(
|
|
(entry) =>
|
|
entry.uses?.endsWith('.github/actions/self-hosted-node') ||
|
|
entry.uses?.startsWith('actions/setup-node@'),
|
|
);
|
|
expect(node.map((entry) => entry.if)).toEqual([
|
|
"${{ runner.environment == 'self-hosted' }}",
|
|
"${{ runner.environment != 'self-hosted' }}",
|
|
]);
|
|
const validate = steps.findIndex((entry) =>
|
|
entry.run?.includes('audio-prebuilds.mjs validate'),
|
|
);
|
|
for (const entry of node)
|
|
expect(steps.indexOf(entry)).toBeLessThan(validate);
|
|
// self-hosted-node is resolved from the helper checkout, so that checkout
|
|
// has to include it.
|
|
expect(
|
|
steps.find((entry) => entry.name === 'Checkout audio workflow helper')
|
|
.with['sparse-checkout'],
|
|
).toContain('.github/actions/self-hosted-node');
|
|
});
|
|
});
|