From 3dd6c07d8c43441c2faf8f56b26b9756a2ae7798 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 15 Apr 2026 08:17:29 +0200 Subject: [PATCH 1/8] get test name for failed tests --- .github/FLAKY_CI_FAILURE_TEMPLATE.md | 2 +- .github/workflows/build.yml | 29 +++++++++++++++++-- .../node-express/tests/errors.test.ts | 5 ++++ .../public-api/setTag/with-primitives/test.ts | 5 ++++ packages/core/test/lib/session.test.ts | 5 ++++ packages/nuxt/vite.config.ts | 1 + vite/vite.config.ts | 2 +- 7 files changed, 44 insertions(+), 5 deletions(-) diff --git a/.github/FLAKY_CI_FAILURE_TEMPLATE.md b/.github/FLAKY_CI_FAILURE_TEMPLATE.md index 2a2ad5109561..5a76fad1baa8 100644 --- a/.github/FLAKY_CI_FAILURE_TEMPLATE.md +++ b/.github/FLAKY_CI_FAILURE_TEMPLATE.md @@ -13,7 +13,7 @@ Other / Unknown ### Name of Test -_Not available - check the run link for details_ +{{ env.TEST_NAME }} ### Link to Test Run diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9dd91e42287f..9e50b31f6b18 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1125,15 +1125,14 @@ jobs: runs-on: ubuntu-24.04 permissions: issues: write + checks: read steps: - name: Check out current commit - if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') uses: actions/checkout@v6 with: sparse-checkout: .github - name: Create issues for failed jobs - if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') uses: actions/github-script@v7 with: script: | @@ -1171,10 +1170,34 @@ jobs: const jobName = job.name; const jobUrl = job.html_url; + // Fetch annotations from the check run to extract failed test names + let testName = '_Not available - check the run link for details_'; + try { + const annotations = await github.paginate(github.rest.checks.listAnnotations, { + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: job.id, + per_page: 100 + }); + + const failureAnnotations = annotations.filter(a => a.annotation_level === 'failure'); + if (failureAnnotations.length > 0) { + const testNames = failureAnnotations.map(a => { + const firstLine = a.message.split('\n')[0]; + return `- \`${a.path}\`: ${firstLine}`; + }); + // Deduplicate and limit to 20 entries to keep issues readable + testName = [...new Set(testNames)].slice(0, 20).join('\n'); + } + } catch (e) { + console.log(`Could not fetch annotations for ${jobName}: ${e.message}`); + } + // Replace template variables const vars = { 'JOB_NAME': jobName, - 'RUN_LINK': jobUrl + 'RUN_LINK': jobUrl, + 'TEST_NAME': testName }; let title = frontmatter.match(/title:\s*'(.*)'/)[1]; diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts index 3a3c821a927d..923474f93765 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts @@ -1,6 +1,11 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +// TEMPORARY: Guaranteed failure to verify test name extraction in CI +test('TEMPORARY - should fail to test annotation extraction', () => { + expect(true).toBe(false); +}); + test('Sends correct error event', async ({ baseURL }) => { const errorEventPromise = waitForError('node-express', event => { return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; diff --git a/dev-packages/node-integration-tests/suites/public-api/setTag/with-primitives/test.ts b/dev-packages/node-integration-tests/suites/public-api/setTag/with-primitives/test.ts index 23e22402c666..f294abfb1a8a 100644 --- a/dev-packages/node-integration-tests/suites/public-api/setTag/with-primitives/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/setTag/with-primitives/test.ts @@ -5,6 +5,11 @@ afterAll(() => { cleanupChildProcesses(); }); +// TEMPORARY: Guaranteed failure to verify test name extraction in CI +test('TEMPORARY - should fail to test annotation extraction', () => { + expect(true).toBe(false); +}); + test('should set primitive tags', async () => { await createRunner(__dirname, 'scenario.ts') .expect({ diff --git a/packages/core/test/lib/session.test.ts b/packages/core/test/lib/session.test.ts index 9d6cfe06e426..a1b43d3ae785 100644 --- a/packages/core/test/lib/session.test.ts +++ b/packages/core/test/lib/session.test.ts @@ -4,6 +4,11 @@ import type { SessionContext } from '../../src/types-hoist/session'; import { timestampInSeconds } from '../../src/utils/time'; describe('Session', () => { + // TEMPORARY: Guaranteed failure to verify test name extraction in CI + it('TEMPORARY - should fail to test annotation extraction', () => { + expect(true).toBe(false); + }); + it('initializes with the proper defaults', () => { const newSession = makeSession(); const session = newSession.toJSON(); diff --git a/packages/nuxt/vite.config.ts b/packages/nuxt/vite.config.ts index 75dc3957244a..9dce31278f4f 100644 --- a/packages/nuxt/vite.config.ts +++ b/packages/nuxt/vite.config.ts @@ -3,6 +3,7 @@ import baseConfig from '../../vite/vite.config'; export default { ...baseConfig, test: { + ...baseConfig.test, environment: 'jsdom', setupFiles: ['./test/vitest.setup.ts'], typecheck: { diff --git a/vite/vite.config.ts b/vite/vite.config.ts index 62f89a570f52..dd7b0020f782 100644 --- a/vite/vite.config.ts +++ b/vite/vite.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ 'vite.config.*', ], }, - reporters: process.env.CI ? ['default', ['junit', { classnameTemplate: '{filepath}' }]] : ['default'], + reporters: process.env.CI ? ['default', 'github-actions', ['junit', { classnameTemplate: '{filepath}' }]] : ['default'], outputFile: { junit: 'vitest.junit.xml', }, From 616cea792d4439ffaef4fab2d910f13013a108e5 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 15 Apr 2026 09:42:18 +0200 Subject: [PATCH 2/8] another test run --- .github/workflows/build.yml | 10 +++++----- .../browser-integration-tests/playwright.config.ts | 2 +- .../suites/public-api/setTag/with-primitives/test.ts | 6 +++--- dev-packages/test-utils/src/playwright-config.ts | 2 +- packages/core/test/lib/session.test.ts | 4 ++-- vite/vite.config.ts | 4 +++- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9e50b31f6b18..600de904e410 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1180,11 +1180,11 @@ jobs: per_page: 100 }); - const failureAnnotations = annotations.filter(a => a.annotation_level === 'failure'); - if (failureAnnotations.length > 0) { - const testNames = failureAnnotations.map(a => { - const firstLine = a.message.split('\n')[0]; - return `- \`${a.path}\`: ${firstLine}`; + const testAnnotations = annotations.filter(a => a.annotation_level === 'failure' && a.path !== '.github'); + if (testAnnotations.length > 0) { + const testNames = testAnnotations.map(a => { + // The title field contains the full test name (e.g. "file.test.ts > Suite > test name") + return a.title ? `- ${a.title}` : `- \`${a.path}\``; }); // Deduplicate and limit to 20 entries to keep issues readable testName = [...new Set(testNames)].slice(0, 20).join('\n'); diff --git a/dev-packages/browser-integration-tests/playwright.config.ts b/dev-packages/browser-integration-tests/playwright.config.ts index 821c0291ccfb..681de57d4e59 100644 --- a/dev-packages/browser-integration-tests/playwright.config.ts +++ b/dev-packages/browser-integration-tests/playwright.config.ts @@ -30,7 +30,7 @@ const config: PlaywrightTestConfig = { }, ], - reporter: process.env.CI ? [['list'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', + reporter: process.env.CI ? [['list'], ['github'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', globalSetup: require.resolve('./playwright.setup.ts'), globalTeardown: require.resolve('./playwright.teardown.ts'), diff --git a/dev-packages/node-integration-tests/suites/public-api/setTag/with-primitives/test.ts b/dev-packages/node-integration-tests/suites/public-api/setTag/with-primitives/test.ts index f294abfb1a8a..1aec2ec4e6d9 100644 --- a/dev-packages/node-integration-tests/suites/public-api/setTag/with-primitives/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/setTag/with-primitives/test.ts @@ -1,12 +1,12 @@ -import { afterAll, test } from 'vitest'; +import { afterAll, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; afterAll(() => { cleanupChildProcesses(); }); -// TEMPORARY: Guaranteed failure to verify test name extraction in CI -test('TEMPORARY - should fail to test annotation extraction', () => { +// TEMPORARY: Guaranteed failure to verify test name extraction in CI (Node 18 only) +test.skipIf(!process.version.startsWith('v18'))('TEMPORARY - should fail to test annotation extraction', () => { expect(true).toBe(false); }); diff --git a/dev-packages/test-utils/src/playwright-config.ts b/dev-packages/test-utils/src/playwright-config.ts index fb15fc325232..823f47380c05 100644 --- a/dev-packages/test-utils/src/playwright-config.ts +++ b/dev-packages/test-utils/src/playwright-config.ts @@ -37,7 +37,7 @@ export function getPlaywrightConfig( /* In dev mode some apps are flaky, so we allow retry there... */ retries: testEnv === 'development' ? 3 : 0, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: process.env.CI ? [['line'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', + reporter: process.env.CI ? [['line'], ['github'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ diff --git a/packages/core/test/lib/session.test.ts b/packages/core/test/lib/session.test.ts index a1b43d3ae785..02d95a4e153f 100644 --- a/packages/core/test/lib/session.test.ts +++ b/packages/core/test/lib/session.test.ts @@ -4,8 +4,8 @@ import type { SessionContext } from '../../src/types-hoist/session'; import { timestampInSeconds } from '../../src/utils/time'; describe('Session', () => { - // TEMPORARY: Guaranteed failure to verify test name extraction in CI - it('TEMPORARY - should fail to test annotation extraction', () => { + // TEMPORARY: Guaranteed failure to verify test name extraction in CI (Node 18 only) + it.skipIf(process.env.NODE_VERSION !== '18')('TEMPORARY - should fail to test annotation extraction', () => { expect(true).toBe(false); }); diff --git a/vite/vite.config.ts b/vite/vite.config.ts index dd7b0020f782..c603d95fe856 100644 --- a/vite/vite.config.ts +++ b/vite/vite.config.ts @@ -19,7 +19,9 @@ export default defineConfig({ 'vite.config.*', ], }, - reporters: process.env.CI ? ['default', 'github-actions', ['junit', { classnameTemplate: '{filepath}' }]] : ['default'], + reporters: process.env.CI + ? ['default', 'github-actions', ['junit', { classnameTemplate: '{filepath}' }]] + : ['default'], outputFile: { junit: 'vitest.junit.xml', }, From 0aeee92347de788baefbce00a8d7f0c1fc07bd5a Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 15 Apr 2026 10:15:03 +0200 Subject: [PATCH 3/8] one issue for each failing test --- .github/FLAKY_CI_FAILURE_TEMPLATE.md | 2 +- .github/workflows/build.yml | 70 ++++++++++++++-------------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/.github/FLAKY_CI_FAILURE_TEMPLATE.md b/.github/FLAKY_CI_FAILURE_TEMPLATE.md index 5a76fad1baa8..c105f6928d27 100644 --- a/.github/FLAKY_CI_FAILURE_TEMPLATE.md +++ b/.github/FLAKY_CI_FAILURE_TEMPLATE.md @@ -1,5 +1,5 @@ --- -title: '[Flaky CI]: {{ env.JOB_NAME }}' +title: '[Flaky CI]: {{ env.JOB_NAME }} - {{ env.TEST_NAME }}' labels: Tests --- diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 600de904e410..c1a2666499aa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1171,7 +1171,7 @@ jobs: const jobUrl = job.html_url; // Fetch annotations from the check run to extract failed test names - let testName = '_Not available - check the run link for details_'; + let testNames = []; try { const annotations = await github.paginate(github.rest.checks.listAnnotations, { owner: context.repo.owner, @@ -1181,48 +1181,48 @@ jobs: }); const testAnnotations = annotations.filter(a => a.annotation_level === 'failure' && a.path !== '.github'); - if (testAnnotations.length > 0) { - const testNames = testAnnotations.map(a => { - // The title field contains the full test name (e.g. "file.test.ts > Suite > test name") - return a.title ? `- ${a.title}` : `- \`${a.path}\``; - }); - // Deduplicate and limit to 20 entries to keep issues readable - testName = [...new Set(testNames)].slice(0, 20).join('\n'); - } + testNames = [...new Set(testAnnotations.map(a => a.title || a.path))]; } catch (e) { console.log(`Could not fetch annotations for ${jobName}: ${e.message}`); } - // Replace template variables - const vars = { - 'JOB_NAME': jobName, - 'RUN_LINK': jobUrl, - 'TEST_NAME': testName - }; - - let title = frontmatter.match(/title:\s*'(.*)'/)[1]; - let issueBody = bodyTemplate; - for (const [key, value] of Object.entries(vars)) { - const pattern = new RegExp(`\\{\\{\\s*env\\.${key}\\s*\\}\\}`, 'g'); - title = title.replace(pattern, value); - issueBody = issueBody.replace(pattern, value); + // If no test names found, fall back to one issue per job + if (testNames.length === 0) { + testNames = ['Unknown test']; } - const existingIssue = existing.find(i => i.title === title); + // Create one issue per failing test for proper deduplication + for (const testName of testNames) { + const vars = { + 'JOB_NAME': jobName, + 'RUN_LINK': jobUrl, + 'TEST_NAME': testName + }; + + let title = frontmatter.match(/title:\s*'(.*)'/)[1]; + let issueBody = bodyTemplate; + for (const [key, value] of Object.entries(vars)) { + const pattern = new RegExp(`\\{\\{\\s*env\\.${key}\\s*\\}\\}`, 'g'); + title = title.replace(pattern, value); + issueBody = issueBody.replace(pattern, value); + } - if (existingIssue) { - console.log(`Issue already exists for ${jobName}: #${existingIssue.number}`); - continue; - } + const existingIssue = existing.find(i => i.title === title); + + if (existingIssue) { + console.log(`Issue already exists for "${testName}" in ${jobName}: #${existingIssue.number}`); + continue; + } - const newIssue = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: issueBody.trim(), - labels: ['Tests'] - }); - console.log(`Created issue #${newIssue.data.number} for ${jobName}`); + const newIssue = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: issueBody.trim(), + labels: ['Tests'] + }); + console.log(`Created issue #${newIssue.data.number} for "${testName}" in ${jobName}`); + } } - name: Check for failures From 59b43c465f09aca47d7f05356c1b64ba4628055f Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 15 Apr 2026 10:42:10 +0200 Subject: [PATCH 4/8] empty commit for testing dedup logic From 02a15db0c2c74c85e4cdf1656bf8d3e83b0637bc Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 15 Apr 2026 11:15:17 +0200 Subject: [PATCH 5/8] revert test changes --- .github/workflows/build.yml | 2 ++ .../test-applications/node-express/tests/errors.test.ts | 5 ----- .../suites/public-api/setTag/with-primitives/test.ts | 7 +------ packages/core/test/lib/session.test.ts | 5 ----- 4 files changed, 3 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c1a2666499aa..d09a912cc680 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1128,11 +1128,13 @@ jobs: checks: read steps: - name: Check out current commit + if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') uses: actions/checkout@v6 with: sparse-checkout: .github - name: Create issues for failed jobs + if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') uses: actions/github-script@v7 with: script: | diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts index 923474f93765..3a3c821a927d 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; -// TEMPORARY: Guaranteed failure to verify test name extraction in CI -test('TEMPORARY - should fail to test annotation extraction', () => { - expect(true).toBe(false); -}); - test('Sends correct error event', async ({ baseURL }) => { const errorEventPromise = waitForError('node-express', event => { return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; diff --git a/dev-packages/node-integration-tests/suites/public-api/setTag/with-primitives/test.ts b/dev-packages/node-integration-tests/suites/public-api/setTag/with-primitives/test.ts index 1aec2ec4e6d9..23e22402c666 100644 --- a/dev-packages/node-integration-tests/suites/public-api/setTag/with-primitives/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/setTag/with-primitives/test.ts @@ -1,15 +1,10 @@ -import { afterAll, expect, test } from 'vitest'; +import { afterAll, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; afterAll(() => { cleanupChildProcesses(); }); -// TEMPORARY: Guaranteed failure to verify test name extraction in CI (Node 18 only) -test.skipIf(!process.version.startsWith('v18'))('TEMPORARY - should fail to test annotation extraction', () => { - expect(true).toBe(false); -}); - test('should set primitive tags', async () => { await createRunner(__dirname, 'scenario.ts') .expect({ diff --git a/packages/core/test/lib/session.test.ts b/packages/core/test/lib/session.test.ts index 02d95a4e153f..9d6cfe06e426 100644 --- a/packages/core/test/lib/session.test.ts +++ b/packages/core/test/lib/session.test.ts @@ -4,11 +4,6 @@ import type { SessionContext } from '../../src/types-hoist/session'; import { timestampInSeconds } from '../../src/utils/time'; describe('Session', () => { - // TEMPORARY: Guaranteed failure to verify test name extraction in CI (Node 18 only) - it.skipIf(process.env.NODE_VERSION !== '18')('TEMPORARY - should fail to test annotation extraction', () => { - expect(true).toBe(false); - }); - it('initializes with the proper defaults', () => { const newSession = makeSession(); const session = newSession.toJSON(); From 79ca3b47ef638cc6935b81b16fd813c3027782bc Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 15 Apr 2026 14:33:12 +0200 Subject: [PATCH 6/8] extract script --- .github/workflows/build.yml | 96 +++-------------------------- scripts/report-ci-failures.mjs | 109 +++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 89 deletions(-) create mode 100644 scripts/report-ci-failures.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3659307b31ee..f4ef407e1126 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1161,101 +1161,19 @@ jobs: if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') uses: actions/checkout@v6 with: - sparse-checkout: .github + sparse-checkout: | + .github + scripts - name: Create issues for failed jobs if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') uses: actions/github-script@v7 with: script: | - const fs = require('fs'); - - // Fetch actual job details from the API to get descriptive names - const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { - owner: context.repo.owner, - repo: context.repo.repo, - run_id: context.runId, - per_page: 100 - }); - - const failedJobs = jobs.filter(job => job.conclusion === 'failure' && !job.name.includes('(optional)')); - - if (failedJobs.length === 0) { - console.log('No failed jobs found'); - return; - } - - // Read and parse template - const template = fs.readFileSync('.github/FLAKY_CI_FAILURE_TEMPLATE.md', 'utf8'); - const [, frontmatter, bodyTemplate] = template.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); - - // Get existing open issues with Tests label - const existing = await github.paginate(github.rest.issues.listForRepo, { - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - labels: 'Tests', - per_page: 100 - }); - - for (const job of failedJobs) { - const jobName = job.name; - const jobUrl = job.html_url; - - // Fetch annotations from the check run to extract failed test names - let testNames = []; - try { - const annotations = await github.paginate(github.rest.checks.listAnnotations, { - owner: context.repo.owner, - repo: context.repo.repo, - check_run_id: job.id, - per_page: 100 - }); - - const testAnnotations = annotations.filter(a => a.annotation_level === 'failure' && a.path !== '.github'); - testNames = [...new Set(testAnnotations.map(a => a.title || a.path))]; - } catch (e) { - console.log(`Could not fetch annotations for ${jobName}: ${e.message}`); - } - - // If no test names found, fall back to one issue per job - if (testNames.length === 0) { - testNames = ['Unknown test']; - } - - // Create one issue per failing test for proper deduplication - for (const testName of testNames) { - const vars = { - 'JOB_NAME': jobName, - 'RUN_LINK': jobUrl, - 'TEST_NAME': testName - }; - - let title = frontmatter.match(/title:\s*'(.*)'/)[1]; - let issueBody = bodyTemplate; - for (const [key, value] of Object.entries(vars)) { - const pattern = new RegExp(`\\{\\{\\s*env\\.${key}\\s*\\}\\}`, 'g'); - title = title.replace(pattern, value); - issueBody = issueBody.replace(pattern, value); - } - - const existingIssue = existing.find(i => i.title === title); - - if (existingIssue) { - console.log(`Issue already exists for "${testName}" in ${jobName}: #${existingIssue.number}`); - continue; - } - - const newIssue = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: issueBody.trim(), - labels: ['Tests'] - }); - console.log(`Created issue #${newIssue.data.number} for "${testName}" in ${jobName}`); - } - } + const { default: run } = await import( + `${process.env.GITHUB_WORKSPACE}/scripts/report-ci-failures.mjs` + ); + await run({ github, context, core }); - name: Check for failures if: cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') diff --git a/scripts/report-ci-failures.mjs b/scripts/report-ci-failures.mjs new file mode 100644 index 000000000000..5a05e1144bec --- /dev/null +++ b/scripts/report-ci-failures.mjs @@ -0,0 +1,109 @@ +/** + * CI Failure Reporter script. + * + * Creates GitHub issues for tests that fail on the develop branch. + * For each failed job in the workflow run, it fetches check run annotations + * to identify individual failing tests, then creates one issue per failing + * test using the FLAKY_CI_FAILURE_TEMPLATE.md template. Existing open issues + * with matching titles are skipped to avoid duplicates. + * + * Intended to be called from a GitHub Actions workflow via actions/github-script: + * + * const { default: run } = await import( + * `${process.env.GITHUB_WORKSPACE}/scripts/report-ci-failures.mjs` + * ); + * await run({ github, context, core }); + */ + +import { readFileSync } from 'node:fs'; + +export default async function run({ github, context, core }) { + const { owner, repo } = context.repo; + + // Fetch actual job details from the API to get descriptive names + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { + owner, + repo, + run_id: context.runId, + per_page: 100, + }); + + const failedJobs = jobs.filter(job => job.conclusion === 'failure' && !job.name.includes('(optional)')); + + if (failedJobs.length === 0) { + core.info('No failed jobs found'); + return; + } + + // Read and parse template + const template = readFileSync('.github/FLAKY_CI_FAILURE_TEMPLATE.md', 'utf8'); + const [, frontmatter, bodyTemplate] = template.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + + // Get existing open issues with Tests label + const existing = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: 'open', + labels: 'Tests', + per_page: 100, + }); + + for (const job of failedJobs) { + const jobName = job.name; + const jobUrl = job.html_url; + + // Fetch annotations from the check run to extract failed test names + let testNames = []; + try { + const annotations = await github.paginate(github.rest.checks.listAnnotations, { + owner, + repo, + check_run_id: job.id, + per_page: 100, + }); + + const testAnnotations = annotations.filter(a => a.annotation_level === 'failure' && a.path !== '.github'); + testNames = [...new Set(testAnnotations.map(a => a.title || a.path))]; + } catch (e) { + core.info(`Could not fetch annotations for ${jobName}: ${e.message}`); + } + + // If no test names found, fall back to one issue per job + if (testNames.length === 0) { + testNames = ['Unknown test']; + } + + // Create one issue per failing test for proper deduplication + for (const testName of testNames) { + const vars = { + JOB_NAME: jobName, + RUN_LINK: jobUrl, + TEST_NAME: testName, + }; + + let title = frontmatter.match(/title:\s*'(.*)'/)[1]; + let issueBody = bodyTemplate; + for (const [key, value] of Object.entries(vars)) { + const pattern = new RegExp(`\\{\\{\\s*env\\.${key}\\s*\\}\\}`, 'g'); + title = title.replace(pattern, value); + issueBody = issueBody.replace(pattern, value); + } + + const existingIssue = existing.find(i => i.title === title); + + if (existingIssue) { + core.info(`Issue already exists for "${testName}" in ${jobName}: #${existingIssue.number}`); + continue; + } + + const newIssue = await github.rest.issues.create({ + owner, + repo, + title, + body: issueBody.trim(), + labels: ['Tests'], + }); + core.info(`Created issue #${newIssue.data.number} for "${testName}" in ${jobName}`); + } + } +} From dc939a7bff524d286383de76ea07b9dee9062f39 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 15 Apr 2026 14:35:42 +0200 Subject: [PATCH 7/8] test --- .github/workflows/build.yml | 4 ++-- packages/core/test/lib/session.test.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f4ef407e1126..3b55901e01a8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1158,7 +1158,7 @@ jobs: checks: read steps: - name: Check out current commit - if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') + if: contains(needs.*.result, 'failure') uses: actions/checkout@v6 with: sparse-checkout: | @@ -1166,7 +1166,7 @@ jobs: scripts - name: Create issues for failed jobs - if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') + if: contains(needs.*.result, 'failure') uses: actions/github-script@v7 with: script: | diff --git a/packages/core/test/lib/session.test.ts b/packages/core/test/lib/session.test.ts index 9d6cfe06e426..92e66c2ce144 100644 --- a/packages/core/test/lib/session.test.ts +++ b/packages/core/test/lib/session.test.ts @@ -4,6 +4,11 @@ import type { SessionContext } from '../../src/types-hoist/session'; import { timestampInSeconds } from '../../src/utils/time'; describe('Session', () => { + // TEMPORARY: Guaranteed failure to verify issue creation in CI + it('TEMPORARY - should fail to test annotation extraction', () => { + expect(true).toBe(false); + }); + it('initializes with the proper defaults', () => { const newSession = makeSession(); const session = newSession.toJSON(); From 9c86b21d733a0f9b78de9438b34b7e0ad4d8c2af Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 15 Apr 2026 15:02:59 +0200 Subject: [PATCH 8/8] Revert "test" This reverts commit dc939a7bff524d286383de76ea07b9dee9062f39. --- .github/workflows/build.yml | 4 ++-- packages/core/test/lib/session.test.ts | 5 ----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b55901e01a8..f4ef407e1126 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1158,7 +1158,7 @@ jobs: checks: read steps: - name: Check out current commit - if: contains(needs.*.result, 'failure') + if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') uses: actions/checkout@v6 with: sparse-checkout: | @@ -1166,7 +1166,7 @@ jobs: scripts - name: Create issues for failed jobs - if: contains(needs.*.result, 'failure') + if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') uses: actions/github-script@v7 with: script: | diff --git a/packages/core/test/lib/session.test.ts b/packages/core/test/lib/session.test.ts index 92e66c2ce144..9d6cfe06e426 100644 --- a/packages/core/test/lib/session.test.ts +++ b/packages/core/test/lib/session.test.ts @@ -4,11 +4,6 @@ import type { SessionContext } from '../../src/types-hoist/session'; import { timestampInSeconds } from '../../src/utils/time'; describe('Session', () => { - // TEMPORARY: Guaranteed failure to verify issue creation in CI - it('TEMPORARY - should fail to test annotation extraction', () => { - expect(true).toBe(false); - }); - it('initializes with the proper defaults', () => { const newSession = makeSession(); const session = newSession.toJSON();