Files
qwen-code/scripts/tests/release-sdk-workflow.test.js
829ef49acf fix(ci): add bounded, idempotent retry to the SDK release branch push (#16705)
* fix(ci): retry sdk release branch push with bounded backoff

The release-sdk workflow pushed the SDK release branch with a single
bare git push. GitHub fails closed when it cannot decide in time
whether the push may create or update workflow files, which aborted
the stable release even when the push had actually landed.

Retry the push up to 3 times with 30s/60s backoff, reconciling with
the remote after each failure: an already-landed push is treated as
success, a diverged remote branch is a hard error, and the branch is
never force-pushed.

Fixes #16490

* fix(ci): reconcile release branch via ls-remote output, not fetch

A bare git fetch in the reconcile path ran unguarded under the
runner's bash -eo pipefail: a transient fetch failure after a
false-negative push would abort the step before the remaining
retries, reproducing the original failure shape. Read the remote tip
from a single ls-remote instead, with || true so a failed lookup
falls back to the plain retry path.

* fix(ci): match release branch by full ref in push reconciliation

git ls-remote --heads matches its pattern against ref tails on slash
boundaries, so a suffixed branch (e.g. backup/release/...) would add
a second line and make a landed push look diverged. Query the full
refs/heads/ ref so exactly one line can come back.

* fix(ci): harden SDK release push retry per round-1 review

- run persist_source under set -euo pipefail so the ls-remote || true guard
  is load-bearing and its rationale accurate
- correct the reconcile comment: the observed #16490 shape left nothing
  remotely, so the bounded re-push is the remedy and the reconcile guards a
  possible partial --follow-tags push
- annotate each absorbed push failure with a ::warning:: naming the attempt
- pin all four loop branches behaviorally in release-sdk-workflow.test.js
  with a stubbed git/sleep harness; flipping the reconcile comparison,
  deleting the divergence abort, or removing the warning turns a case red

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: qwen-code-dev <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
2026-11-09 13:42:23 +08:00

222 lines
7.7 KiB
JavaScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } 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 { parse } from 'yaml';
import { afterEach, describe, expect, it } from 'vitest';
describe('TypeScript SDK release workflow', () => {
const workflow = readFileSync('.github/workflows/release-sdk.yml', 'utf8');
it('only creates a release PR when the package version changed', () => {
expect(workflow).toContain("id: 'persist_source'");
expect(workflow).toContain(
'echo "HAS_PERSISTED_SOURCE=false" >> "${GITHUB_OUTPUT}"',
);
expect(workflow).toContain(
'echo "HAS_PERSISTED_SOURCE=true" >> "${GITHUB_OUTPUT}"',
);
expect(workflow).toContain(
'echo "::notice::No version changes in sdk-typescript; skipping release branch push and PR creation."',
);
expect(workflow).toContain(
"HAS_PERSISTED_SOURCE: '${{ steps.persist_source.outputs.HAS_PERSISTED_SOURCE }}'",
);
expect(workflow).toContain(
'elif [[ "${HAS_PERSISTED_SOURCE}" == "true" ]]',
);
expect(workflow).toContain('TARGET="${RELEASE_BRANCH}"');
expect(workflow).toContain('TARGET="${REF}"');
for (const stepName of [
'Create GitHub Release and Tag',
'Create PR to merge release branch into main',
'Enable auto-merge for release PR',
]) {
const stepStart = workflow.indexOf(`name: '${stepName}'`);
const step = workflow.slice(
stepStart,
workflow.indexOf('\n - name:', stepStart + 1),
);
expect(step).toContain(
"steps.persist_source.outputs.HAS_PERSISTED_SOURCE == 'true'",
);
}
});
it('spaces PR creation out from the release to avoid secondary rate limits', () => {
// `gh pr create` runs right after the branch push and release creation,
// which can trip GitHub's "was submitted too quickly" GraphQL secondary
// rate limit (issue #11402). Pin the gap in every release workflow so a
// future removal lets the burst through again.
for (const path of [
'.github/workflows/release-sdk.yml',
'.github/workflows/release-sdk-python.yml',
'.github/workflows/finalize-release.yml',
]) {
const text = readFileSync(path, 'utf8');
const createIndex = text.indexOf('pr_url="$(gh pr create');
// -1 when the create is gone or no `sleep 30` precedes it.
const sleepIndex = text.lastIndexOf('sleep 30', createIndex);
expect(sleepIndex, path).toBeGreaterThanOrEqual(0);
}
});
});
// Behavioral pins for the release-branch push retry/reconcile loop: the
// step body runs verbatim under bash with a stubbed git and sleep, so a
// collapse back to a single push, a flipped reconcile comparison, or a
// removed divergence abort turns a case red here instead of during a
// real stable SDK release.
describe.skipIf(process.platform === 'win32')(
'SDK release branch push retry',
() => {
const dirs = [];
afterEach(() =>
dirs
.splice(0)
.forEach((dir) => rmSync(dir, { recursive: true, force: true })),
);
const HEAD_SHA = 'a'.repeat(40);
const OTHER_SHA = 'b'.repeat(40);
function runPushStep({ failPushes, remoteSha }) {
const root = mkdtempSync(join(tmpdir(), 'sdk-push-retry-'));
dirs.push(root);
const bin = join(root, 'bin');
mkdirSync(bin);
const log = join(root, 'calls');
writeFileSync(log, '');
writeFileSync(
join(bin, 'git'),
`#!/usr/bin/env node
const fs = require('node:fs');
const args = process.argv.slice(2);
fs.appendFileSync(process.env.GIT_LOG, JSON.stringify(args) + '\\n');
const pushes = () => fs.readFileSync(process.env.GIT_LOG, 'utf8')
.trim().split('\\n').filter(Boolean).map((l) => JSON.parse(l))
.filter((a) => a[0] === 'push').length;
if (args[0] === 'push') {
if (pushes() <= Number(process.env.FAIL_PUSHES)) {
console.error('fatal: simulated push failure');
process.exit(1);
}
} else if (args[0] === 'diff') {
process.exit(1);
} else if (args[0] === 'ls-remote') {
if (args.includes('--exit-code')) process.exit(2);
if (process.env.REMOTE_SHA) console.log(process.env.REMOTE_SHA + '\\trefs/heads/' + process.env.BRANCH_NAME);
} else if (args[0] === 'rev-parse') {
console.log(process.env.HEAD_SHA);
}
`,
);
chmodSync(join(bin, 'git'), 0o755);
writeFileSync(join(bin, 'sleep'), '#!/bin/sh\nexit 0\n');
chmodSync(join(bin, 'sleep'), 0o755);
const workflow = parse(
readFileSync('.github/workflows/release-sdk.yml', 'utf8'),
);
const step = workflow.jobs['release-sdk'].steps.find(
(s) => s.id === 'persist_source',
);
const result = spawnSync('bash', ['-c', step.run], {
cwd: root,
encoding: 'utf8',
env: {
...process.env,
PATH: `${bin}:${process.env.PATH}`,
GITHUB_OUTPUT: join(root, 'output'),
GIT_LOG: log,
BRANCH_NAME: 'release/sdk-typescript/v9.9.9',
RELEASE_TAG: 'v9.9.9',
FAIL_PUSHES: String(failPushes),
REMOTE_SHA: remoteSha ?? '',
HEAD_SHA,
},
});
const calls = readFileSync(log, 'utf8')
.trim()
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line));
return {
result,
pushes: calls.filter((args) => args[0] === 'push'),
output: result.stdout + result.stderr,
};
}
it('runs the step under set -euo pipefail so the || true guard is load-bearing', () => {
const workflow = parse(
readFileSync('.github/workflows/release-sdk.yml', 'utf8'),
);
const step = workflow.jobs['release-sdk'].steps.find(
(s) => s.id === 'persist_source',
);
expect(step.run.startsWith('set -euo pipefail')).toBe(true);
});
it('pushes once with no delay when the first push succeeds', () => {
const { result, pushes, output } = runPushStep({ failPushes: 0 });
expect(result.status, result.stderr).toBe(0);
expect(pushes).toHaveLength(1);
expect(output).not.toContain('Push attempt');
expect(output).not.toContain('landed remotely');
});
it('accepts a push that landed remotely despite the reported failure', () => {
const { result, pushes, output } = runPushStep({
failPushes: 3,
remoteSha: HEAD_SHA,
});
expect(result.status, result.stderr).toBe(0);
expect(pushes).toHaveLength(1);
expect(output).toContain('landed remotely');
});
it('fails closed when the remote branch diverged and never force-pushes', () => {
const { result, pushes, output } = runPushStep({
failPushes: 3,
remoteSha: OTHER_SHA,
});
expect(result.status).not.toBe(0);
expect(pushes).toHaveLength(1);
expect(output).toContain('diverged');
for (const args of pushes) {
expect(args).not.toContain('--force');
expect(args).not.toContain('--force-with-lease');
}
});
it('retries a push that never landed and gives up after three attempts', () => {
const { result, pushes, output } = runPushStep({ failPushes: 3 });
expect(result.status).not.toBe(0);
expect(pushes).toHaveLength(3);
expect(output).toContain('after 3 attempts');
expect(output).toContain('Push attempt 1/3');
expect(output).toContain('Push attempt 2/3');
});
it('annotates an absorbed failure and recovers on the next attempt', () => {
const { result, pushes, output } = runPushStep({ failPushes: 1 });
expect(result.status, result.stderr).toBe(0);
expect(pushes).toHaveLength(2);
expect(output).toContain('Push attempt 1/3');
});
},
);