diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 2b0669a8421..17537203820 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -563,12 +563,11 @@ jobs: # Design D2: the desktop ships every target's addons, merged from the lanes that qualified them. desktop_template: - needs: [persistence, mobile_web_bundle, linux_glibc_floor, linux_glibc217_compat, linux_musl] - if: >- - ${{ !cancelled() && inputs.build_template && - needs.persistence.result == 'success' && needs.mobile_web_bundle.result == 'success' && - needs.linux_glibc_floor.result == 'success' && - needs.linux_glibc217_compat.result == 'success' && needs.linux_musl.result == 'success' }} + needs: changes + if: ${{ !cancelled() && inputs.build_template }} + permissions: + contents: read + actions: read runs-on: ${{ inputs.fast_runners && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 30 steps: @@ -577,6 +576,45 @@ jobs: ref: ${{ inputs.ref }} persist-credentials: false - uses: ./.github/actions/install-node-dependencies + - name: Wait for every template qualification lane + uses: actions/github-script@v8 + with: + script: | + const deadline = Date.now() + 25 * 60_000 + const expected = { + persistence: 6, + mobile_web_bundle: 1, + linux_glibc_floor: 2, + linux_glibc217_compat: 1, + linux_musl: 2 + } + while (Date.now() < deadline) { + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { + ...context.repo, run_id: context.runId, filter: 'latest', per_page: 100 + }) + const assemblers = jobs.filter(job => + job.name === 'desktop_template' || job.name.endsWith(' / desktop_template')) + if (assemblers.length > 1) throw new Error('Ambiguous desktop template qualification') + if (assemblers.length === 1) { + const prefix = assemblers[0].name.slice(0, -'desktop_template'.length) + let ready = true + for (const [name, count] of Object.entries(expected)) { + const lanes = jobs.filter(job => job.name === `${prefix}${name}` || + job.name.startsWith(`${prefix}${name} (`)) + if (lanes.length > count) throw new Error(`Unexpected qualification lanes: ${name}`) + for (const lane of lanes) { + if (lane.status === 'completed' && lane.conclusion !== 'success') { + throw new Error(`Template qualification did not succeed: ${lane.name} (${lane.conclusion})`) + } + } + if (lanes.length !== count || lanes.some(lane => lane.status !== 'completed')) ready = false + } + if (ready) return + } + await new Promise(resolve => setTimeout(resolve, 15_000)) + } + throw new Error('Timed out waiting for every template qualification lane') + - name: Collect every lane's slot uses: actions/download-artifact@v8 with: diff --git a/config/scripts/node-server-release-parallelism.test.mjs b/config/scripts/node-server-release-parallelism.test.mjs index 6546ed7bc31..ce63ba332d7 100644 --- a/config/scripts/node-server-release-parallelism.test.mjs +++ b/config/scripts/node-server-release-parallelism.test.mjs @@ -73,28 +73,23 @@ it('starts independent Linux qualification without waiting for another platform' } }) -it('requires every platform and compatibility result before publishing a desktop template', () => { +it('prepares template dependencies before waiting for every qualification lane', () => { const template = workflow.jobs.desktop_template - const required = [ - 'persistence', - 'mobile_web_bundle', - 'linux_glibc_floor', - 'linux_glibc217_compat', - 'linux_musl' - ] - expect(template.needs).toEqual(required) - const evaluate = compileAggregateExpression(template.if, ['needs', 'inputs']) - const needs = Object.fromEntries(required.map((name) => [name, { result: 'success' }])) - const ctx = { needs, inputs: { build_template: true } } - expect(evaluate(ctx).coerceString()).toBe('true') - expect(evaluate(ctx, true).coerceString()).toBe('false') - for (const name of required) { - for (const result of ['failure', 'cancelled', 'skipped']) { - expect(evaluate({ ...ctx, needs: { ...needs, [name]: { result } } }).coerceString()).toBe( - 'false' - ) - } - } + expect(template.needs).toBe('changes') + expect(template.permissions).toEqual({ contents: 'read', actions: 'read' }) + const evaluate = compileAggregateExpression(template.if, ['inputs']) + expect(evaluate({ inputs: { build_template: true } }).coerceString()).toBe('true') + expect(evaluate({ inputs: { build_template: false } }).coerceString()).toBe('false') + expect(evaluate({ inputs: { build_template: true } }, true).coerceString()).toBe('false') + const wait = template.steps.findIndex( + (step) => step.name === 'Wait for every template qualification lane' + ) + expect( + template.steps.findIndex((step) => step.uses?.includes('install-node-dependencies')) + ).toBeLessThan(wait) + expect( + template.steps.findIndex((step) => step.name === "Collect every lane's slot") + ).toBeGreaterThan(wait) }) it('avoids template download-cache transfers while retaining native and verification caches', () => { @@ -106,7 +101,11 @@ it('avoids template download-cache transfers while retaining native and verifica for (const os of ['Linux', 'macOS', 'Windows']) { for (const buildTemplate of [true, false]) { expect( - runInNewContext(source, { inputs: { build_template: buildTemplate }, runner: { os } }) + runInNewContext(source, { + inputs: { build_template: buildTemplate, fast_runners: false }, + matrix: { os: '' }, + runner: { os } + }) ).toBe(!buildTemplate && os !== 'Windows') } } diff --git a/config/scripts/orcad-mobile-web-bundle.test.mjs b/config/scripts/orcad-mobile-web-bundle.test.mjs index 44a5c417013..f1a0e8b76e7 100644 --- a/config/scripts/orcad-mobile-web-bundle.test.mjs +++ b/config/scripts/orcad-mobile-web-bundle.test.mjs @@ -65,8 +65,13 @@ it('ships the page the qualification lanes tested in the release template', () = readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8') ) const template = workflow.jobs.desktop_template - expect(template.needs).toContain('mobile_web_bundle') - expect(template.if).toContain("needs.mobile_web_bundle.result == 'success'") + const wait = template.steps.findIndex( + (step) => step.name === 'Wait for every template qualification lane' + ) + expect(template.steps[wait].with.script).toContain('mobile_web_bundle: 1') + expect(wait).toBeLessThan( + template.steps.findIndex((step) => step.with?.name === 'orcad-mobile-web-bundle') + ) expect(template.steps.some((step) => step.with?.name === 'orcad-mobile-web-bundle')).toBe(true) expect(template.steps.some((step) => step.uses?.includes('build-mobile-web-bundle'))).toBe(false) }) diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index 498fd9b01ac..4df557d73ea 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -32,7 +32,7 @@ const EXPECTED_MATRIX = { 'persistence' ].map((job) => [ `.github/workflows/release-mac-build.yml#qualification-template -> .github/workflows/qualified-orcad-template.yml#qualify -> .github/workflows/node-server-tests.yml#${job}`, - { contents: 'read' } + job === 'desktop_template' ? { actions: 'read', contents: 'read' } : { contents: 'read' } ]) ), ...Object.fromEntries( @@ -68,6 +68,7 @@ const EXPECTED_MATRIX = { }, [`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/qualified-orcad-template.yml#qualify -> .github/workflows/node-server-tests.yml#desktop_template`]: { + actions: 'read', contents: 'read' }, [`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/qualified-orcad-template.yml#qualify -> .github/workflows/node-server-tests.yml#linux_glibc217_compat`]: diff --git a/config/scripts/template-qualification-wait.test.mjs b/config/scripts/template-qualification-wait.test.mjs new file mode 100644 index 00000000000..23b506cf8fb --- /dev/null +++ b/config/scripts/template-qualification-wait.test.mjs @@ -0,0 +1,90 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const { jobs } = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8')) +const step = jobs.desktop_template.steps.find( + (item) => item.name === 'Wait for every template qualification lane' +) +const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor +function lanes(prefix = 'orcad-template / qualify / ') { + return [ + { name: `${prefix}desktop_template`, status: 'in_progress' }, + ...Object.entries({ + persistence: 6, + mobile_web_bundle: 1, + linux_glibc_floor: 2, + linux_glibc217_compat: 1, + linux_musl: 2 + }).flatMap(([name, count]) => + Array.from({ length: count }, (_, i) => ({ + name: `${prefix}${name}${count > 1 ? ` (platform-${i})` : ''}`, + status: 'completed', + conclusion: 'success' + })) + ) + ] +} +function execute(snapshots) { + let now = 0 + let index = 0 + const calls = [] + const github = { + rest: { actions: { listJobsForWorkflowRun: 'jobs' } }, + async paginate(endpoint, input) { + calls.push({ endpoint, input }) + return snapshots[Math.min(index, snapshots.length - 1)] + } + } + return { + calls, + result: new AsyncFunction('github', 'context', 'Date', 'setTimeout', step.with.script)( + github, + { repo: { owner: 'owner', repo: 'repo' }, runId: 42 }, + { now: () => now }, + (resolve) => { + now += 5000 + index++ + resolve() + } + ) + } +} + +describe('early template assembly qualification gate', () => { + it.each(['', 'orcad-template / qualify / '])( + 'waits for all current-attempt jobs with prefix %s', + async (prefix) => { + const incomplete = lanes(prefix) + incomplete[1] = { ...incomplete[1], status: 'in_progress', conclusion: null } + const { calls, result } = execute([incomplete, lanes(prefix)]) + await result + expect(calls).toHaveLength(2) + expect(calls.every(({ input }) => input.filter === 'latest' && input.run_id === 42)).toBe( + true + ) + } + ) + it.each(['failure', 'cancelled', 'skipped', 'timed_out', 'action_required', 'startup_failure'])( + 'rejects %s from any lane', + async (conclusion) => { + for (let i = 1; i < lanes().length; i++) { + const input = lanes() + input[i].conclusion = conclusion + await expect(execute([input]).result).rejects.toThrow('qualification did not succeed') + } + } + ) + it('fails closed for missing, extra, ambiguous and unrelated jobs', async () => { + await expect(execute([lanes().slice(0, -1)]).result).rejects.toThrow('Timed out') + await expect( + execute([[...lanes(), { ...lanes()[1], name: `${lanes()[1].name}-extra` }]]).result + ).rejects.toThrow('Unexpected') + await expect(execute([[...lanes(), ...lanes('other / ')]]).result).rejects.toThrow('Ambiguous') + await expect( + execute([ + [{ name: 'desktop_template', status: 'in_progress' }, ...lanes('unrelated / ').slice(1)] + ]).result + ).rejects.toThrow('Timed out') + }) +})