fix(release): avoid API rate limits waiting for the mobile bundle (#31431)

* fix(release): replace mobile bundle API polling with job dependencies

* test(release): update qualification token permission expectations
This commit is contained in:
Neil
2026-10-20 23:49:57 -07:00
committed by GitHub
parent 92732bb0f9
commit a04b2ebc33
5 changed files with 36 additions and 142 deletions
+6 -38
View File
@@ -136,10 +136,9 @@ jobs:
fi
persistence:
needs: changes
needs: [changes, mobile_web_bundle]
permissions:
contents: read
actions: read
concurrency:
group: node-server-persistence-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }}
@@ -147,6 +146,7 @@ jobs:
# Drafts re-trigger at readiness; inherited caller events must not depend on PR fields.
if: >-
${{ !cancelled() && needs.changes.outputs.should_run != 'false' &&
(needs.changes.outputs.qualification == 'false' || needs.mobile_web_bundle.result == 'success') &&
(github.event_name != 'pull_request' || github.event.pull_request.draft != true) }}
strategy:
fail-fast: false
@@ -213,32 +213,6 @@ jobs:
if: runner.os == 'Windows'
with:
arches: ${{ (inputs.build_template && matrix.os == 'windows-2022') && 'x64 arm64' || (runner.arch == 'ARM64' && 'arm64' || 'x64') }}
- name: Wait for the qualified mobile bundle
if: needs.changes.outputs.qualification != 'false'
uses: actions/github-script@v8
with: &mobile-bundle-wait
script: |
const deadline = Date.now() + 15 * 60_000
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 producers = jobs.filter(job => job.name === 'mobile_web_bundle' ||
job.name.endsWith(' / mobile_web_bundle'))
if (producers.length > 1) throw new Error('Ambiguous mobile bundle producer')
const producer = producers[0]
if (producer?.status === 'completed') {
if (producer.conclusion !== 'success') {
throw new Error(`Mobile bundle production did not succeed: ${producer.conclusion}`)
}
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
...context.repo, run_id: context.runId, per_page: 100
})
if (artifacts.some(artifact => artifact.name === 'orcad-mobile-web-bundle' && !artifact.expired)) return
}
await new Promise(resolve => setTimeout(resolve, 5_000))
}
throw new Error('Timed out waiting for the qualified mobile bundle')
- uses: actions/download-artifact@v8
if: needs.changes.outputs.qualification != 'false'
with:
@@ -379,10 +353,9 @@ jobs:
overwrite: true
linux_glibc_floor:
needs: changes
needs: [changes, mobile_web_bundle]
permissions:
contents: read
actions: read
concurrency:
group: node-server-linux_glibc_floor-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }}
@@ -391,6 +364,7 @@ jobs:
${{ !cancelled() &&
needs.changes.outputs.should_run != 'false' &&
needs.changes.outputs.qualification != 'false' &&
needs.mobile_web_bundle.result == 'success' &&
(github.event_name != 'pull_request' || github.event.pull_request.draft != true) }}
strategy:
fail-fast: false
@@ -438,9 +412,6 @@ jobs:
pnpm build:orcad-prebuilds
pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)"
pnpm build:orcad-prebuilds --smoke
- name: Wait for the qualified mobile bundle
uses: actions/github-script@v8
with: *mobile-bundle-wait
- uses: actions/download-artifact@v8
with:
name: orcad-mobile-web-bundle
@@ -515,10 +486,9 @@ jobs:
overwrite: true
linux_musl:
needs: changes
needs: [changes, mobile_web_bundle]
permissions:
contents: read
actions: read
concurrency:
group: node-server-linux_musl-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }}
@@ -526,6 +496,7 @@ jobs:
${{ !cancelled() &&
needs.changes.outputs.should_run != 'false' &&
needs.changes.outputs.qualification != 'false' &&
needs.mobile_web_bundle.result == 'success' &&
(github.event_name != 'pull_request' || github.event.pull_request.draft != true) }}
strategy:
fail-fast: false
@@ -566,9 +537,6 @@ jobs:
pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)"
pnpm build:orcad-prebuilds --smoke
NODE_SERVER_PREPARATION
- name: Wait for the qualified mobile bundle
uses: actions/github-script@v8
with: *mobile-bundle-wait
- uses: actions/download-artifact@v8
with:
name: orcad-mobile-web-bundle
@@ -571,11 +571,11 @@ it('keeps every platform job and runs them when detection is skipped or fails',
expect(workflow.jobs.persistence.strategy.matrix.os).toContain('needs.changes.outputs.runners')
for (const jobName of ['linux_glibc_floor', 'linux_musl']) {
const job = workflow.jobs[jobName]
expect(job.needs).toBe('changes')
expect(job.needs).toEqual(['changes', 'mobile_web_bundle'])
// The containers never install mobile/, and orcad refuses to build without the page.
expect(job.steps.some((step) => step.with?.name === 'orcad-mobile-web-bundle')).toBe(true)
expect(job.steps.some((step) => step.name === 'Wait for the qualified mobile bundle')).toBe(
true
false
)
expect(job.if).not.toContain('needs.persistence')
expect(job.if).toContain("needs.changes.outputs.qualification != 'false'")
@@ -329,7 +329,9 @@ it('builds server glibc slots on glibc 2.28 and the compat slot on glibc 2.17 (d
const compat = workflow.jobs.linux_glibc217_compat
expect(compat.needs).toBe('changes')
expect(compat.if).toContain(floor.if.slice(0, -3))
expect(compat.if).toContain(
floor.if.replace("needs.mobile_web_bundle.result == 'success' && ", '').slice(0, -3)
)
expect(compat.if).toContain("needs.changes.outputs.native_compatibility != 'false'")
expect(compat.if).toContain(
"github.event_name != 'pull_request' || inputs.build_template || inputs.ref != ''"
@@ -21,17 +21,17 @@ function context({ qualification, producer = 'success', event = 'pull_request',
it.each([
{ qualification: 'true', producer: 'success', expected: true },
{ qualification: '', producer: 'success', expected: true },
{ qualification: 'true', producer: 'failure', expected: true },
{ qualification: 'true', producer: 'cancelled', expected: true },
{ qualification: 'true', producer: 'skipped', expected: true },
{ qualification: '', producer: 'skipped', expected: true },
{ qualification: 'true', producer: 'failure', expected: false },
{ qualification: 'true', producer: 'cancelled', expected: false },
{ qualification: 'true', producer: 'skipped', expected: false },
{ qualification: '', producer: 'skipped', expected: false },
{ qualification: 'false', producer: 'skipped', expected: true },
{ qualification: 'false', producer: 'skipped', draft: true, expected: false },
{ producer: 'success', event: 'workflow_dispatch', expected: true },
{ producer: 'success', event: 'schedule', expected: true }
])('starts native setup independently while retaining test-only behavior: %j', (scenario) => {
])('gates qualification on the bundle while retaining test-only behavior: %j', (scenario) => {
const ctx = context(scenario)
expect(persistence.needs).toBe('changes')
expect(persistence.needs).toEqual(['changes', 'mobile_web_bundle'])
expect(shouldPersist(ctx).coerceString()).toBe(String(scenario.expected))
expect(shouldPersist(ctx, true).coerceString()).toBe('false')
})
@@ -112,107 +112,36 @@ it('avoids template download-cache transfers while retaining native and verifica
}
})
const mobileWait = persistence.steps.find(
(step) => step.name === 'Wait for the qualified mobile bundle'
)
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor
function waitForMobile(snapshots) {
let now = 0
let index = 0
const calls = []
const github = {
rest: { actions: { listJobsForWorkflowRun: 'jobs', listWorkflowRunArtifacts: 'artifacts' } },
async paginate(endpoint, input) {
calls.push({ endpoint, input })
return snapshots[Math.min(index, snapshots.length - 1)][endpoint] ?? []
it.each(['persistence', 'linux_glibc_floor', 'linux_musl'])(
'%s waits through the dependency graph without polling the Actions API',
(name) => {
const job = workflow.jobs[name]
expect(job.needs).toEqual(['changes', 'mobile_web_bundle'])
expect(job.steps.some((step) => step.uses?.includes('github-script'))).toBe(false)
const evaluate = compileAggregateExpression(job.if, ['needs', 'github', 'inputs'])
for (const producer of ['success', 'failure', 'cancelled', 'skipped', 'timed_out']) {
const ctx = {
...context({ qualification: 'true', producer }),
inputs: { build_template: true }
}
expect(evaluate(ctx).coerceString()).toBe(String(producer === 'success'))
}
}
const execute = new AsyncFunction(
'github',
'context',
'Date',
'setTimeout',
mobileWait.with.script
)
return {
calls,
result: execute(
github,
{ repo: { owner: 'owner', repo: 'repo' }, runId: 42 },
{ now: () => now },
(resolve, delay) => {
now += delay
index++
resolve()
}
)
}
}
const mobileProducer = (conclusion, prefix = 'orcad-template / qualify / ') => ({
name: `${prefix}mobile_web_bundle`,
status: conclusion ? 'completed' : 'in_progress',
conclusion
})
const mobileArtifact = { name: 'orcad-mobile-web-bundle', expired: false }
it.each(['', 'orcad-template / ', 'orcad-template / qualify / '])(
'waits for the same-run producer to succeed before accepting its artifact (%s)',
async (prefix) => {
const { result, calls } = waitForMobile([
{ artifacts: [mobileArtifact] },
{ jobs: [mobileProducer(undefined, prefix)], artifacts: [mobileArtifact] },
{
jobs: [mobileProducer('success', prefix)],
artifacts: [{ ...mobileArtifact, expired: true }]
},
{ jobs: [mobileProducer('success', prefix)], artifacts: [mobileArtifact] }
])
await result
expect(calls.filter((call) => call.endpoint === 'jobs')).toHaveLength(4)
expect(calls.filter((call) => call.endpoint === 'artifacts')).toHaveLength(2)
expect(calls.every((call) => call.input.run_id === 42)).toBe(true)
expect(
calls
.filter((call) => call.endpoint === 'jobs')
.every((call) => call.input.filter === 'latest')
).toBe(true)
}
)
it.each(['failure', 'cancelled', 'skipped', 'timed_out', 'action_required', 'startup_failure'])(
'rejects a %s producer even when an earlier attempt left an artifact',
async (conclusion) => {
await expect(
waitForMobile([{ jobs: [mobileProducer(conclusion)], artifacts: [mobileArtifact] }]).result
).rejects.toThrow('Mobile bundle production did not succeed')
}
)
it('fails closed when the producer is missing or ambiguous', async () => {
await expect(waitForMobile([{}]).result).rejects.toThrow('Timed out')
await expect(
waitForMobile([{ jobs: [mobileProducer('success'), mobileProducer('success', '')] }]).result
).rejects.toThrow('Ambiguous')
})
it('prepares every native lane before waiting and builds with the shared page afterward', () => {
it('prepares every native lane and builds with the shared page', () => {
for (const name of ['persistence', 'linux_glibc_floor', 'linux_musl']) {
const job = workflow.jobs[name]
const wait = job.steps.findIndex((step) => step.name === mobileWait.name)
const download = job.steps.findIndex((step) => step.with?.name === mobileArtifact.name)
const download = job.steps.findIndex((step) => step.with?.name === 'orcad-mobile-web-bundle')
const prepare = job.steps.findIndex(
(step) =>
step.uses?.includes('prepare-orcad-prebuilds') ||
step.run?.includes('pnpm build:orcad-prebuilds')
)
expect(job.needs).toBe('changes')
expect(job.needs).toEqual(['changes', 'mobile_web_bundle'])
expect(prepare).toBeGreaterThan(-1)
expect(prepare).toBeLessThan(wait)
expect(wait).toBeLessThan(download)
expect(job.steps[wait].with.script).toBe(mobileWait.with.script)
expect(job.permissions).toEqual({ contents: 'read', actions: 'read' })
expect(prepare).toBeLessThan(download)
expect(job.permissions).toEqual({ contents: 'read' })
}
const alpine = workflow.jobs.linux_musl.steps
const prepare = alpine.find((step) => step.name === 'Prepare native Alpine artifact')
@@ -32,9 +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}`,
['persistence', 'linux_glibc_floor', 'linux_musl'].includes(job)
? { actions: 'read', contents: 'read' }
: { contents: 'read' }
{ contents: 'read' }
])
),
...Object.fromEntries(
@@ -78,12 +76,10 @@ const EXPECTED_MATRIX = {
},
[`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/qualified-orcad-template.yml#qualify -> .github/workflows/node-server-tests.yml#linux_glibc_floor`]:
{
actions: 'read',
contents: 'read'
},
[`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/qualified-orcad-template.yml#qualify -> .github/workflows/node-server-tests.yml#linux_musl`]:
{
actions: 'read',
contents: 'read'
},
[`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/qualified-orcad-template.yml#qualify -> .github/workflows/node-server-tests.yml#mobile_web_bundle`]:
@@ -92,7 +88,6 @@ const EXPECTED_MATRIX = {
},
[`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/qualified-orcad-template.yml#qualify -> .github/workflows/node-server-tests.yml#persistence`]:
{
actions: 'read',
contents: 'read'
},
[`${RELEASE_WORKFLOW}#post-release-e2e`]: { actions: 'write' },