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
13 changes: 9 additions & 4 deletions .github/workflows/release-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ jobs:
path: |
tmp/newman-report*.json
tmp/newman-cli*.log
tmp/provider-harness-report.html
tmp/harness-failures.md
tmp/harness-token-parity.md
tmp/stream-cancel-report.json
Expand All @@ -341,6 +342,9 @@ jobs:
chmod +x ./.github/workflows/scripts/configure-r2.sh ./.github/workflows/scripts/upload-test-reports-to-r2.sh
./.github/workflows/scripts/configure-r2.sh || { echo "::warning::R2 not configured; skipping report publish"; exit 0; }
mkdir -p tmp/provider-harness-report
# index.html so the R2 key serves as a browsable page, matching the
# cli-harness reports the changelog links alongside it.
cp tmp/provider-harness-report.html tmp/provider-harness-report/index.html 2>/dev/null || true
cp tmp/harness-failures.md tmp/harness-token-parity.md tmp/provider-harness-report/ 2>/dev/null || true
./.github/workflows/scripts/upload-test-reports-to-r2.sh \
"${{ needs.detect-changes.outputs.transport-version }}" \
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -486,14 +490,14 @@ jobs:
include: ${{ fromJSON(needs.resolve-cli-versions.outputs.matrix) }}
name: test-cli-harness (${{ matrix.label }})
runs-on: ubuntu-latest
# The four run_cases invocations in test-cli-harness.sh each pass
# TIMEOUT=25m, so the Go suites alone can take 100m on top of the CLI
# The five run_cases invocations in test-cli-harness.sh each pass
# TIMEOUT=25m, so the Go suites alone can take 125m on top of the CLI
# installs, the UI build and the gateway build. A job timeout cancels the
# run, and the report upload is `if: !cancelled()`, so a cancel loses the
# harness artifacts too. Raised twice: 90 -> 120 when the opencode suite was
# added and the core scenarios went multi-turn, 120 -> 150 for the
# opencode-responses suite.
timeout-minutes: 150
# opencode-responses suite, 150 -> 180 for opencode-anthropic.
timeout-minutes: 180
permissions:
contents: read
steps:
Expand Down Expand Up @@ -615,6 +619,7 @@ jobs:
tmp/cli-harness-codex.log
tmp/cli-harness-opencode.log
tmp/cli-harness-opencode-responses.log
tmp/cli-harness-opencode-anthropic.log
# Per-attempt snapshots, written when a suite needed retries. The
# reports above are overwritten in place by each rerun, so this is
# the only record of what the first attempt failed on.
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/scripts/detect-all-changes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,25 @@ echo " Bifrost HTTP: $BIFROST_HTTP_NEEDS_RELEASE (v$TRANSPORT_VERSION)"
echo " Docker: $DOCKER_NEEDS_RELEASE (v$TRANSPORT_VERSION)"

# Set outputs (only when running in GitHub Actions)
# Version strings are read straight out of the repo's version files and are then
# interpolated by callers into shell commands (the R2 upload steps, the changelog
# push, the docker manifest). A value carrying a quote, a space or a newline would
# break out of the generated command, and GITHUB_OUTPUT is line-oriented so a
# newline would additionally forge an extra output line. Nothing downstream can
# defend against that once it is written, so it is rejected here, at the single
# point where these values enter the workflow.
for _pair in "core:$CORE_VERSION" "framework:$FRAMEWORK_VERSION" "transport:$TRANSPORT_VERSION"; do
_name="${_pair%%:*}"
_value="${_pair#*:}"
case "$_value" in
"") continue ;;
*[!0-9A-Za-z.+_-]*)
echo "❌ refusing to emit $_name version with unexpected characters: $_value" >&2
exit 1
;;
esac
done

if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "core-needs-release=$CORE_NEEDS_RELEASE"
Expand Down
52 changes: 26 additions & 26 deletions .github/workflows/scripts/push-mintlify-changelog.sh
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ $CLI_HARNESS_INTRO

| Suite | What it covers | Report |
| --- | --- | --- |
| Provider harness | Every provider × modality through a live gateway | [failure breakdown]($REPORTS_BASE/provider-harness/harness-failures.md) |
| Provider harness | Every provider × modality through a live gateway | [requests]($REPORTS_BASE/provider-harness/index.html) · [failure breakdown]($REPORTS_BASE/provider-harness/harness-failures.md) |
$CLI_HARNESS_ROWS

The CLI harness reports include the full conversation for every scenario - each
Expand Down Expand Up @@ -201,18 +201,18 @@ if ! grep -q "\"$route\"" docs/docs.json; then
node -e "
const fs = require('fs');
const docs = JSON.parse(fs.readFileSync('docs/docs.json', 'utf8'));

// Semantic version comparison function
// Extracts version from route/filename and compares in descending order (newest first)
function compareVersionsDesc(a, b) {
// Extract route string from string or object
const routeA = typeof a === 'string' ? a : '';
const routeB = typeof b === 'string' ? b : '';

// Extract version from route (e.g., 'changelogs/v1.3.34' -> 'v1.3.34')
const versionA = routeA.split('/').pop() || '';
const versionB = routeB.split('/').pop() || '';

// Remove 'v' prefix and split into parts
const partsA = versionA.replace(/^v/, '').split(/[.-]/).map(p => {
const num = parseInt(p, 10);
Expand All @@ -222,7 +222,7 @@ if ! grep -q "\"$route\"" docs/docs.json; then
const num = parseInt(p, 10);
return isNaN(num) ? p : num;
});

// Compare each part (major, minor, patch, pre-release, etc.)
const maxLength = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLength; i++) {
Expand All @@ -233,10 +233,10 @@ if ! grep -q "\"$route\"" docs/docs.json; then
if (partsB[i] === undefined && partsA[i] !== undefined) {
return 1; // B (release) comes first in descending order
}

const partA = partsA[i];
const partB = partsB[i];

// If both are numbers, compare numerically
if (typeof partA === 'number' && typeof partB === 'number') {
if (partA !== partB) {
Expand All @@ -248,7 +248,7 @@ if ! grep -q "\"$route\"" docs/docs.json; then
const strB = String(partB);
const matchA = strA.match(/^([a-zA-Z]+)(\\d+)$/);
const matchB = strB.match(/^([a-zA-Z]+)(\\d+)$/);

if (matchA && matchB && matchA[1] === matchB[1]) {
// Same prefix, compare numbers numerically
const numA = parseInt(matchA[2], 10);
Expand All @@ -261,45 +261,45 @@ if ! grep -q "\"$route\"" docs/docs.json; then
}
}
}

return 0; // Equal
}

// Sort a pages array by semver (descending)
function sortPagesBySemver(pages) {
return pages.slice().sort(compareVersionsDesc);
}

// Get current month/year
const releaseDate = new Date('$CURRENT_DATE');
const currentDate = new Date();
const releaseMonthYear = releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
const currentMonthYear = currentDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });

// Find the Changelogs tab
const changelogsTab = docs.navigation.tabs.find(tab => tab.tab === 'Changelogs');
if (!changelogsTab) {
console.error('Changelogs tab not found');
process.exit(1);
}

// Find the Open Source menu item
const openSourceItem = changelogsTab.menu?.find(item => item.item === 'Open Source');
if (!openSourceItem) {
console.error('Open Source menu item not found in Changelogs tab');
process.exit(1);
}

// Get all top-level entries and existing groups
const topLevelEntries = openSourceItem.pages.filter(p => typeof p === 'string');
const existingGroups = openSourceItem.pages.filter(p => typeof p === 'object');

// Check if we need to group existing top-level entries
if (topLevelEntries.length > 0) {
// Get the month of the first top-level entry (they should all be from same month)
const firstEntryPath = topLevelEntries[0].replace('changelogs/', '') + '.mdx';
const firstEntryFile = 'docs/changelogs/' + firstEntryPath;

let topLevelMonth = null;
try {
const content = fs.readFileSync(firstEntryFile, 'utf8');
Expand All @@ -311,22 +311,22 @@ if ! grep -q "\"$route\"" docs/docs.json; then
} catch (e) {
console.log(\`Warning: Could not read entry file \${firstEntryFile}: \${e.message}\`);
}

// Only group if the month has changed
if (topLevelMonth && topLevelMonth !== releaseMonthYear) {
console.log(\`📦 Month changed from \${topLevelMonth} to \${releaseMonthYear}\`);
console.log(\`📦 Grouping \${topLevelEntries.length} top-level entries into \${topLevelMonth} group...\`);

// Create a group for all existing top-level entries
const previousMonthGroup = {
group: topLevelMonth,
pages: sortPagesBySemver(topLevelEntries)
};

// Add this group at the top of existing groups
existingGroups.unshift(previousMonthGroup);
console.log(\`✅ Created \${topLevelMonth} group with \${topLevelEntries.length} entries (sorted)\`);

// Clear top-level entries (they're now in the group)
openSourceItem.pages = existingGroups;
} else {
Expand All @@ -335,30 +335,30 @@ if ! grep -q "\"$route\"" docs/docs.json; then
openSourceItem.pages = [...topLevelEntries, ...existingGroups];
}
}

const newRoute = '$route';

// Add the new changelog at the top level
openSourceItem.pages.unshift(newRoute);
console.log(\`✅ Added \${newRoute} to top level\`);

// Sort the top-level pages array by semver
const topLevelPages = openSourceItem.pages.filter(p => typeof p === 'string');
const groupPages = openSourceItem.pages.filter(p => typeof p === 'object');

if (topLevelPages.length > 0) {
const sortedTopLevel = sortPagesBySemver(topLevelPages);
openSourceItem.pages = [...sortedTopLevel, ...groupPages];
console.log(\`✅ Sorted \${topLevelPages.length} top-level pages by semver\`);
}

// Sort each group's pages by semver
for (const group of groupPages) {
if (group.pages && Array.isArray(group.pages)) {
group.pages = sortPagesBySemver(group.pages);
}
}

fs.writeFileSync('docs/docs.json', JSON.stringify(docs, null, 2) + '\n');
console.log('✅ Updated docs.json');
"
Expand Down
25 changes: 22 additions & 3 deletions .github/workflows/scripts/test-cli-harness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,22 @@ OPENCODE_CASES="${OPENCODE_CASES:-TestCLIs/opencode/(openai|azure)/gpt-5\.5/(sim
# suite that costs Bedrock quota.
OPENCODE_RESPONSES_CASES="${OPENCODE_RESPONSES_CASES:-TestCLIs/opencode-responses/bedrock/global\.anthropic\.claude-sonnet-5/(simple-chat|reasoning-replay)}"

# opencode-anthropic covers the THIRD wire format: /anthropic/v1/messages.
#
# It is the only client here that replays reasoning, and that is a protocol
# constraint rather than a client preference -- the Anthropic API requires an
# assistant turn's thinking blocks to be sent back verbatim when that turn
# contains tool_use. The Responses SDK is free to rebuild history as prose and
# does exactly that, which is why no amount of turns or tools makes the
# opencode-responses suite exercise reasoning replay.
#
# reasoning-tool-replay is therefore the case that matters: it is the only cell
# in the whole harness that reproduces the reported Bedrock 400
# ("messages.2 ... reasoningContent.reasoningText.text ... Member must not be
# null"). simple-chat is its control, and running both providers gives the
# Anthropic wire coverage on the native path as well as the Bedrock one.
OPENCODE_ANTHROPIC_CASES="${OPENCODE_ANTHROPIC_CASES:-TestCLIs/opencode-anthropic/(anthropic|bedrock)/(claude-sonnet-5|global\.anthropic\.claude-sonnet-5)/(simple-chat|reasoning-tool-replay)}"

# Retries per suite, re-running ONLY the cells that failed. Mirrors test-core's
# RERUN_FAILED policy (see test-provider-harness.sh): this suite is a required
# release gate, so a flaky cell must not sink a release on its own, while a real
Expand Down Expand Up @@ -350,12 +366,14 @@ CLAUDE_RC=0
CODEX_RC=0
OPENCODE_RC=0
OPENCODE_RESPONSES_RC=0
# All four run even if an earlier one fails, so one broken CLI does not mask
OPENCODE_ANTHROPIC_RC=0
# All five run even if an earlier one fails, so one broken CLI does not mask
# the others.
run_cases "claude" "$CLAUDE_CASES" || CLAUDE_RC=$?
run_cases "codex" "$CODEX_CASES" || CODEX_RC=$?
run_cases "opencode" "$OPENCODE_CASES" || OPENCODE_RC=$?
run_cases "opencode-responses" "$OPENCODE_RESPONSES_CASES" || OPENCODE_RESPONSES_RC=$?
run_cases "opencode-anthropic" "$OPENCODE_ANTHROPIC_CASES" || OPENCODE_ANTHROPIC_RC=$?

# Renders tests/e2e/clis/reports/index.html from the reports/*.json just written.
# Free and instant - no test re-execution.
Expand All @@ -379,6 +397,7 @@ if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
echo "| codex | \`$CODEX_VERSION\` | \`$CODEX_CASES\` | $(attempts_for codex) | $([ "$CODEX_RC" -eq 0 ] && echo "✅ pass" || echo "❌ fail") |"
echo "| opencode | \`$OPENCODE_VERSION\` | \`$OPENCODE_CASES\` | $(attempts_for opencode) | $([ "$OPENCODE_RC" -eq 0 ] && echo "✅ pass" || echo "❌ fail") |"
echo "| opencode-responses | \`$OPENCODE_VERSION\` | \`$OPENCODE_RESPONSES_CASES\` | $(attempts_for opencode-responses) | $([ "$OPENCODE_RESPONSES_RC" -eq 0 ] && echo "✅ pass" || echo "❌ fail") |"
echo "| opencode-anthropic | \`$OPENCODE_VERSION\` | \`$OPENCODE_ANTHROPIC_CASES\` | $(attempts_for opencode-anthropic) | $([ "$OPENCODE_ANTHROPIC_RC" -eq 0 ] && echo "✅ pass" || echo "❌ fail") |"
echo ""
echo "Attempts > 1 means failed cells were re-run; only cells with status \`fail\` are retried."
echo "Full per-cell results are in the \`cli-harness-reports${CLI_VERSION_LABEL:+-$CLI_VERSION_LABEL}\` artifact (\`index.html\`),"
Expand All @@ -391,8 +410,8 @@ if [ ! -d "$REPORTS_DIR" ]; then
echo "⚠️ No reports directory at $REPORTS_DIR - the harness may not have run any cells"
fi

if [ "$CLAUDE_RC" -ne 0 ] || [ "$CODEX_RC" -ne 0 ] || [ "$OPENCODE_RC" -ne 0 ] || [ "$OPENCODE_RESPONSES_RC" -ne 0 ]; then
echo "❌ CLI harness failed (claude=$CLAUDE_RC, codex=$CODEX_RC, opencode=$OPENCODE_RC, opencode-responses=$OPENCODE_RESPONSES_RC)"
if [ "$CLAUDE_RC" -ne 0 ] || [ "$CODEX_RC" -ne 0 ] || [ "$OPENCODE_RC" -ne 0 ] || [ "$OPENCODE_RESPONSES_RC" -ne 0 ] || [ "$OPENCODE_ANTHROPIC_RC" -ne 0 ]; then
echo "❌ CLI harness failed (claude=$CLAUDE_RC, codex=$CODEX_RC, opencode=$OPENCODE_RC, opencode-responses=$OPENCODE_RESPONSES_RC, opencode-anthropic=$OPENCODE_ANTHROPIC_RC)"
exit 1
fi

Expand Down
42 changes: 42 additions & 0 deletions .github/workflows/scripts/test-provider-harness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,48 @@ while [ "$HARNESS_EXIT" -ne 0 ] && [ "$attempt" -le "$RERUN_ATTEMPTS" ]; do
attempt=$((attempt + 1))
done

# Render the browsable HTML report.
#
# newman's own htmlextra reporter only emits one in sequential mode, and this
# runs PARALLEL=1 (one fork per provider, no way to merge N HTML documents) - so
# without this, the provider harness ships no HTML at all while the CLI harness
# does. harness-viewer.mjs already renders exactly this JSON for local use, so
# --static reuses its markup rather than growing a second renderer that can
# drift from it.
#
# Best-effort: a rendering failure must not fail a passing test run.
echo ""
echo "📊 Rendering provider harness HTML report..."
if node "$REPO_ROOT/tests/e2e/api/runners/harness-viewer.mjs" \
--report "$REPO_ROOT/tmp/newman-report.json" \
--failures-md "$REPO_ROOT/tmp/harness-failures.md" \
--token-parity-md "$REPO_ROOT/tmp/harness-token-parity.md" \
--static "$REPO_ROOT/tmp/provider-harness-report.html"; then
:
else
echo "⚠️ HTML report rendering failed; the JSON and markdown artifacts are still intact"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Rendering is best effort, but the LINK to it is not: the release changelog
# points at provider-harness/index.html unconditionally, and the upload step
# publishes whatever is in this directory. Without a file here that link 404s
# in public release notes. A stub that points at the markdown siblings keeps
# the notes honest about what happened and still reaches the real data.
cat > "$REPO_ROOT/tmp/provider-harness-report.html" <<'FALLBACK_HTML'
<!doctype html>
<meta charset="utf-8">
<title>Bifrost Provider Harness Report</title>
<style>body{font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;background:#0d1117;color:#e6edf3;margin:0;padding:48px;line-height:1.6}
a{color:#58a6ff}code{background:#161b22;padding:2px 6px;border-radius:4px}</style>
<h1>Provider harness report unavailable</h1>
<p>The harness ran and its results were collected, but rendering this HTML view
failed. The underlying data is unaffected and published alongside this page:</p>
<ul>
<li><a href="harness-failures.md">harness-failures.md</a> - the failure breakdown and coverage matrices</li>
<li><a href="harness-token-parity.md">harness-token-parity.md</a> - the direct-provider vs Bifrost token parity matrix</li>
</ul>
<p>The full <code>newman-report.json</code> is attached to the release workflow run as an artifact.</p>
FALLBACK_HTML
fi

if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f "$REPO_ROOT/tmp/harness-failures.md" ]; then
{
echo "## Provider harness failure breakdown"
Expand Down
Loading
Loading