mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
271 lines
10 KiB
JavaScript
271 lines
10 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const releaseWorkflow = readFileSync('.github/workflows/release.yml', 'utf8');
|
|
const finalizeWorkflow = readFileSync(
|
|
'.github/workflows/finalize-release.yml',
|
|
'utf8',
|
|
);
|
|
const releaseNotesScript = readFileSync(
|
|
'scripts/generate-release-notes.js',
|
|
'utf8',
|
|
);
|
|
const releaseStepScript = readFileSync(
|
|
'.github/scripts/run-release-step.sh',
|
|
'utf8',
|
|
);
|
|
|
|
function getStep(workflow, name) {
|
|
const match = new RegExp(
|
|
`\\n - name: '${name}'[\\s\\S]*?(?=\\n - name: '|\\n [A-Za-z0-9_-]+:|$)`,
|
|
).exec(`\n${workflow}`);
|
|
if (!match) {
|
|
throw new Error(`Could not find workflow step: ${name}`);
|
|
}
|
|
return match[0];
|
|
}
|
|
|
|
describe('stable release notes workflow', () => {
|
|
it('publishes immediately with GitHub-generated notes', () => {
|
|
expect(releaseStepScript).toContain(
|
|
'repos/${GITHUB_REPOSITORY}/releases/generate-notes',
|
|
);
|
|
expect(releaseStepScript).toContain(
|
|
'-f "previous_tag_name=${PREVIOUS_RELEASE_TAG}"',
|
|
);
|
|
expect(releaseStepScript).toContain('"${notes_args[@]}"');
|
|
expect(releaseStepScript).toContain('--notes-file "${notes_file}"');
|
|
// Stable tags live on their own release/* branch and are merged back to
|
|
// main only afterwards, so the previous tag is never an ancestor of the
|
|
// branch being released. Anchoring on ancestry dropped the anchor on every
|
|
// stable release, and unanchored notes span the whole branch history and
|
|
// overrun the 125000 character body limit.
|
|
expect(releaseStepScript).not.toContain('git merge-base --is-ancestor');
|
|
expect(releaseStepScript).toContain(
|
|
'node .release-workflow/.github/scripts/cap-release-notes.mjs',
|
|
);
|
|
expect(releaseStepScript).toContain('--file "${notes_file}"');
|
|
// gh prints the API error payload on stdout, so a failed attempt's output
|
|
// must not survive into the release body.
|
|
expect(releaseStepScript).toContain(
|
|
'generate_notes > "${notes_file}" || : > "${notes_file}"',
|
|
);
|
|
// Step-scoped on purpose: release.yml sets this exact token on two steps,
|
|
// 'Create GitHub Release and Tag' and 'Trigger ECS runner qwen update', so
|
|
// a workflow-wide toContain is satisfied by the other step's occurrence and
|
|
// can never fail on the step it was written to protect.
|
|
const createReleaseStep = getStep(
|
|
releaseWorkflow,
|
|
'Create GitHub Release and Tag',
|
|
);
|
|
expect(createReleaseStep).toContain(
|
|
"GITHUB_TOKEN: '${{ secrets.CI_BOT_PAT }}'",
|
|
);
|
|
expect(releaseWorkflow).not.toContain(
|
|
"name: 'Generate AI-assisted stable release notes'",
|
|
);
|
|
expect(releaseWorkflow).not.toContain("name: 'Regenerate CHANGELOG.md'");
|
|
expect(releaseWorkflow).not.toContain(
|
|
"name: 'Create PR to merge release branch into main'",
|
|
);
|
|
});
|
|
|
|
it('finalizes stable releases asynchronously', () => {
|
|
const validate = getStep(finalizeWorkflow, 'Validate stable release tag');
|
|
const checkout = getStep(finalizeWorkflow, 'Checkout release branch');
|
|
const install = getStep(finalizeWorkflow, 'Install Dependencies');
|
|
const generate = getStep(
|
|
finalizeWorkflow,
|
|
'Generate AI-assisted release notes',
|
|
);
|
|
const update = getStep(finalizeWorkflow, 'Update GitHub Release notes');
|
|
const changelog = getStep(finalizeWorkflow, 'Regenerate CHANGELOG.md');
|
|
|
|
expect(
|
|
finalizeWorkflow.slice(0, finalizeWorkflow.indexOf('jobs:')),
|
|
).not.toContain('CI_BOT_PAT');
|
|
expect(install).not.toContain('CI_BOT_PAT');
|
|
|
|
expect(finalizeWorkflow).toContain("types: ['published']");
|
|
expect(finalizeWorkflow).toContain(
|
|
'github.event.release.prerelease == false',
|
|
);
|
|
expect(finalizeWorkflow).toContain('workflow_dispatch:');
|
|
expect(finalizeWorkflow).toContain(
|
|
'if [[ "${TAG}" =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+$ ]]',
|
|
);
|
|
expect(validate).toContain('is not a stable release tag');
|
|
expect(validate).toContain('exit 1');
|
|
expect(checkout).toContain('persist-credentials: false');
|
|
expect(install).toContain(
|
|
'corepack pnpm install --frozen-lockfile --ignore-scripts --prefer-offline --reporter=append-only',
|
|
);
|
|
expect(install).toContain('if [ -f pnpm-lock.yaml ]');
|
|
expect(install).toContain(
|
|
'npm ci --ignore-scripts --no-audit --progress=false',
|
|
);
|
|
expect(install).toContain('npm run postinstall');
|
|
expect(install).toContain('npm run generate');
|
|
expect(install).not.toContain('QWEN_SKIP_PREPARE');
|
|
expect(generate).toContain('timeout-minutes: 35');
|
|
expect(generate).toContain('continue-on-error: true');
|
|
|
|
// The step timeout must exceed the script's internal budget; otherwise
|
|
// the runner SIGKILLs the step and even the fallback notes are lost.
|
|
const stepTimeoutMin = Number(
|
|
generate.match(/timeout-minutes:\s*(\d+)/)[1],
|
|
);
|
|
const budgetMs = releaseNotesScript
|
|
.match(/totalTimeoutMs\s*=\s*([\d_]+)\s*\*\s*([\d_]+)/)
|
|
.slice(1)
|
|
.map((part) => Number(part.replace(/_/g, '')))
|
|
.reduce((a, b) => a * b, 1);
|
|
expect(stepTimeoutMin * 60_000).toBeGreaterThan(budgetMs);
|
|
|
|
expect(generate).toContain('GitHub-generated notes');
|
|
expect(generate).toContain('node scripts/generate-release-notes.js');
|
|
expect(update).toContain('continue-on-error: true');
|
|
expect(update).toContain(
|
|
'gh release edit "${RELEASE_TAG}" --notes-file "${RELEASE_NOTES_FILE}"',
|
|
);
|
|
expect(changelog).not.toContain('continue-on-error: true');
|
|
expect(changelog).toContain("GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'");
|
|
expect(changelog).toContain('gh auth setup-git');
|
|
expect(changelog.indexOf('gh auth setup-git')).toBeLessThan(
|
|
changelog.indexOf('git push origin "${BRANCH_NAME}"'),
|
|
);
|
|
});
|
|
|
|
it('updates the changelog before opening the release PR', () => {
|
|
const changelog = finalizeWorkflow.indexOf(
|
|
"name: 'Regenerate CHANGELOG.md'",
|
|
);
|
|
const pr = finalizeWorkflow.indexOf(
|
|
"name: 'Create PR to merge release branch into main'",
|
|
);
|
|
|
|
expect(changelog).toBeGreaterThanOrEqual(0);
|
|
expect(pr).toBeGreaterThan(changelog);
|
|
expect(finalizeWorkflow).toContain("name: 'Approve release PR'");
|
|
expect(finalizeWorkflow).toContain(
|
|
"name: 'Enable auto-merge for release PR'",
|
|
);
|
|
});
|
|
|
|
it('comments released-in version only for squash-merge PR trailers', () => {
|
|
const step = getStep(
|
|
finalizeWorkflow,
|
|
'Comment released-in version on merged PRs',
|
|
);
|
|
|
|
expect(step).toContain('continue-on-error: true');
|
|
expect(step).toContain("grep -oE '\\(#[0-9]+\\)$'");
|
|
expect(step).toContain("tr -d '()#'");
|
|
expect(step).not.toContain("grep -oE '#[0-9]+'");
|
|
expect(step).toContain("marker='<!-- qwen-release-comment:v1 -->'");
|
|
expect(step).toContain('gh pr view "${num}" --json comments');
|
|
expect(step).toContain('grep -qF "${marker}" <<<"${existing}"');
|
|
expect(step).toContain('gh pr comment "${num}" --body "${body}"');
|
|
});
|
|
|
|
it.each([
|
|
['new', '', 0, ['list', 'create', 'update-branch']],
|
|
['open', 'OPEN', 0, ['list', 'update-branch']],
|
|
['merged retry', 'MERGED', 0, ['list']],
|
|
['update conflict', 'OPEN', 1, ['list', 'update-branch']],
|
|
])(
|
|
'refreshes the release PR safely: %s',
|
|
(_name, state, updateStatus, calls) => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'finalize-release-'));
|
|
const output = join(dir, 'output');
|
|
const log = join(dir, 'calls');
|
|
const url = 'https://github.com/QwenLM/qwen-code/pull/15468';
|
|
const step = getStep(
|
|
finalizeWorkflow,
|
|
'Create PR to merge release branch into main',
|
|
);
|
|
const script = step.split(' run: |-\n')[1].replace(/^ {10}/gm, '');
|
|
try {
|
|
writeFileSync(output, '');
|
|
writeFileSync(log, '');
|
|
writeFileSync(
|
|
join(dir, 'gh'),
|
|
`#!/bin/bash
|
|
set -euo pipefail
|
|
[[ "$1" == pr ]]
|
|
printf '%s\\n' "$2" >> "$CALL_LOG"
|
|
case "$2" in
|
|
list)
|
|
if [[ -n "$PR_STATE" ]]; then
|
|
printf '%s\\t%s\\n' "$PR_URL" "$PR_STATE"
|
|
fi
|
|
;;
|
|
create) printf '%s\\n' "$PR_URL" ;;
|
|
update-branch)
|
|
[[ "$#" == 3 && "$3" == "$PR_URL" ]]
|
|
[[ ! -s "$GITHUB_OUTPUT" ]]
|
|
exit "$UPDATE_STATUS"
|
|
;;
|
|
*) exit 99 ;;
|
|
esac
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
writeFileSync(join(dir, 'sleep'), '#!/bin/bash\nexit 0\n', {
|
|
mode: 0o755,
|
|
});
|
|
const result = spawnSync('bash', ['-e', '-c', script], {
|
|
cwd: dir,
|
|
encoding: 'utf8',
|
|
timeout: 5000,
|
|
env: {
|
|
PATH: `${dir}:${process.env.PATH}`,
|
|
GITHUB_OUTPUT: output,
|
|
CALL_LOG: log,
|
|
PR_URL: url,
|
|
PR_STATE: state,
|
|
UPDATE_STATUS: String(updateStatus),
|
|
RELEASE_BRANCH: 'release/v0.26.1',
|
|
RELEASE_TAG: 'v0.26.1',
|
|
},
|
|
});
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status, result.stderr).toBe(updateStatus);
|
|
expect(readFileSync(log, 'utf8').trim().split('\n')).toEqual(calls);
|
|
expect(readFileSync(output, 'utf8')).toBe(
|
|
updateStatus === 0
|
|
? `PR_URL=${url}\nSHOULD_MERGE=${state !== 'MERGED'}\n`
|
|
: '',
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it('does not recreate an already merged release PR during retries', () => {
|
|
const pr = getStep(
|
|
finalizeWorkflow,
|
|
'Create PR to merge release branch into main',
|
|
);
|
|
const approve = getStep(finalizeWorkflow, 'Approve release PR');
|
|
const merge = getStep(finalizeWorkflow, 'Enable auto-merge for release PR');
|
|
|
|
expect(pr).toContain('--state all');
|
|
expect(pr).toContain('select(.state == "MERGED")');
|
|
expect(pr).toContain('if [[ "${pr_state}" == "MERGED" ]]');
|
|
expect(pr).toContain('SHOULD_MERGE=false');
|
|
expect(approve).toContain("steps.pr.outputs.SHOULD_MERGE == 'true'");
|
|
expect(merge).toContain("steps.pr.outputs.SHOULD_MERGE == 'true'");
|
|
});
|
|
});
|