Overlap native qualification setup with the mobile bundle build (#31336)

* Prepare server native lanes alongside the shared mobile bundle

* Expect read-only Actions access in template callers
This commit is contained in:
Neil
2026-10-20 19:57:29 -07:00
committed by GitHub
parent fece03b5ac
commit 71cf31bf69
12 changed files with 225 additions and 35 deletions
+1
View File
@@ -221,6 +221,7 @@ jobs:
if: needs.orcad-template-support.outputs.ships == 'true'
permissions:
contents: read
actions: read
uses: ./.github/workflows/adhoc-orcad-template.yml
with:
ref: ${{ needs.resolve-ref.outputs.sha }}
@@ -69,6 +69,9 @@ jobs:
retention-days: 7
qualify:
permissions:
contents: read
actions: read
needs: cache
if: needs.cache.outputs.hit != 'true'
uses: ./.github/workflows/node-server-tests.yml
+1
View File
@@ -94,6 +94,7 @@ jobs:
needs: resolve-main
permissions:
contents: read
actions: read
uses: ./.github/workflows/node-server-tests.yml
with:
ref: ${{ needs.resolve-main.outputs.head_sha }}
+1
View File
@@ -122,6 +122,7 @@ jobs:
if: needs.preflight.outputs.should_build == 'true'
permissions:
contents: read
actions: read
uses: ./.github/workflows/node-server-tests.yml
with:
ref: ${{ needs.preflight.outputs.head_sha }}
+65 -15
View File
@@ -136,7 +136,10 @@ jobs:
fi
persistence:
needs: [changes, mobile_web_bundle]
needs: changes
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 == '' }}
@@ -144,7 +147,6 @@ 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
@@ -211,6 +213,32 @@ 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:
@@ -351,16 +379,19 @@ jobs:
overwrite: true
linux_glibc_floor:
needs: [changes, mobile_web_bundle]
needs: changes
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 == '' }}
# Each Linux lane owns its inputs; the template still requires every lane to pass.
if: >-
${{ !cancelled() &&
needs.mobile_web_bundle.result == 'success' &&
needs.changes.outputs.should_run != 'false' &&
needs.changes.outputs.qualification != 'false' }}
needs.changes.outputs.qualification != 'false' &&
(github.event_name != 'pull_request' || github.event.pull_request.draft != true) }}
strategy:
fail-fast: false
matrix:
@@ -407,6 +438,9 @@ 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
@@ -481,15 +515,18 @@ jobs:
overwrite: true
linux_musl:
needs: [changes, mobile_web_bundle]
needs: changes
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 == '' }}
if: >-
${{ !cancelled() &&
needs.mobile_web_bundle.result == 'success' &&
needs.changes.outputs.should_run != 'false' &&
needs.changes.outputs.qualification != 'false' }}
needs.changes.outputs.qualification != 'false' &&
(github.event_name != 'pull_request' || github.event.pull_request.draft != true) }}
strategy:
fail-fast: false
matrix:
@@ -507,21 +544,19 @@ jobs:
id: pnpm-verification
with:
container-toolchain: 'true'
- uses: actions/download-artifact@v8
with:
name: orcad-mobile-web-bundle
path: out/mobile-web
- name: Verify native Alpine artifact and persistence
- name: Prepare native Alpine artifact
env:
VERIFICATION_CACHE_PATH: ${{ steps.pnpm-verification.outputs.path }}
run: |
touch "$VERIFICATION_CACHE_PATH"
mkdir -p out/mobile-web
# Multi-arch index digest of the tag; re-resolve it whenever NODE_RUNTIME_PIN moves.
docker run --rm --init -i \
docker run --rm --init --detach --name orcad-alpine \
-e ORCA_BACKGROUND_LAUNCH=1 \
-v "$VERIFICATION_CACHE_PATH:/root/.cache/pnpm/lockfile-verified.jsonl" \
-v "$GITHUB_WORKSPACE:/work" -w /work \
node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 sh -s <<'NODE_SERVER_QUALIFICATION'
node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 sleep infinity
docker exec -i orcad-alpine sh -s <<'NODE_SERVER_PREPARATION'
set -eu
apk add --no-cache bash git libstdc++ python3 make g++
git config --global --add safe.directory /work
@@ -530,9 +565,24 @@ 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
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
path: out/mobile-web
- name: Verify native Alpine artifact and persistence
run: |
docker exec -i orcad-alpine sh -s <<'NODE_SERVER_QUALIFICATION'
set -eu
pnpm build:orcad
pnpm test:node-server --artifact
NODE_SERVER_QUALIFICATION
- name: Clean up the Alpine qualification container
if: always()
run: docker rm --force orcad-alpine
- name: Keep this runner's musl slot for the desktop template
if: inputs.build_template
uses: actions/upload-artifact@v7
+1
View File
@@ -1183,6 +1183,7 @@ jobs:
if: needs.cut.outputs.should_release == 'true' && needs.cut.outputs.ships_orcad_template == 'true'
permissions:
contents: read
actions: read
uses: ./.github/workflows/node-server-tests.yml
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
+1
View File
@@ -39,6 +39,7 @@ jobs:
if: inputs.qualification_only
permissions:
contents: read
actions: read
uses: ./.github/workflows/node-server-tests.yml
with:
ref: ${{ github.sha }}
@@ -34,7 +34,7 @@ describe.each(channels)('$name build ships the orcad template', (channel) => {
const template = jobs['orcad-template']
expect(template.uses).toBe('./.github/workflows/node-server-tests.yml')
expect(template.with).toEqual({ ref: channel.pinnedSha, build_template: true })
expect(template.permissions).toEqual({ contents: 'read' })
expect(template.permissions).toEqual({ contents: 'read', actions: 'read' })
expect(template.secrets).toBeUndefined()
expect(mac.steps.find((step) => step.name === 'Checkout').with.ref).toBe(channel.pinnedSha)
})
@@ -571,10 +571,12 @@ 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).toEqual(['changes', 'mobile_web_bundle'])
expect(job.needs).toBe('changes')
// 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.if).toContain("needs.mobile_web_bundle.result == 'success'")
expect(job.steps.some((step) => step.name === 'Wait for the qualified mobile bundle')).toBe(
true
)
expect(job.if).not.toContain('needs.persistence')
expect(job.if).toContain("needs.changes.outputs.qualification != 'false'")
expect(job.if).toContain("needs.changes.outputs.should_run != 'false'")
@@ -329,10 +329,7 @@ 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')
// Only the floor builds orcad, so only it waits for the page.
expect(compat.if).toContain(
floor.if.slice(0, -3).replace(" && needs.mobile_web_bundle.result == 'success'", '')
)
expect(compat.if).toContain(floor.if.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: false },
{ qualification: 'true', producer: 'cancelled', expected: false },
{ qualification: 'true', producer: 'skipped', expected: false },
{ qualification: '', producer: 'skipped', expected: false },
{ qualification: 'true', producer: 'failure', expected: true },
{ qualification: 'true', producer: 'cancelled', expected: true },
{ qualification: 'true', producer: 'skipped', expected: true },
{ qualification: '', producer: 'skipped', expected: true },
{ 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 }
])('preserves mobile producer failure and test-only behavior: %j', (scenario) => {
])('starts native setup independently while retaining test-only behavior: %j', (scenario) => {
const ctx = context(scenario)
expect(persistence.needs).toEqual(['changes', 'mobile_web_bundle'])
expect(persistence.needs).toBe('changes')
expect(shouldPersist(ctx).coerceString()).toBe(String(scenario.expected))
expect(shouldPersist(ctx, true).coerceString()).toBe('false')
})
@@ -111,3 +111,121 @@ 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] ?? []
}
}
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', () => {
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 prepare = job.steps.findIndex(
(step) =>
step.uses?.includes('prepare-orcad-prebuilds') ||
step.run?.includes('pnpm build:orcad-prebuilds')
)
expect(job.needs).toBe('changes')
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' })
}
const alpine = workflow.jobs.linux_musl.steps
const prepare = alpine.find((step) => step.name === 'Prepare native Alpine artifact')
const verify = alpine.find(
(step) => step.name === 'Verify native Alpine artifact and persistence'
)
const cleanup = alpine.find((step) => step.name === 'Clean up the Alpine qualification container')
expect(prepare.run).toContain('mkdir -p out/mobile-web')
expect(prepare.run).toContain('--detach --name orcad-alpine')
expect(prepare.run).toContain('docker exec -i orcad-alpine')
expect(prepare.run).not.toContain('pnpm build:orcad\n')
expect(verify.run).toContain('docker exec -i orcad-alpine')
expect(verify.run).toContain('pnpm build:orcad\n')
expect(verify.run).toContain('pnpm test:node-server --artifact')
expect(cleanup.if).toBe('always()')
})
@@ -19,12 +19,18 @@ const EXPECTED_MATRIX = {
'.github/workflows/node-server-tests.yml#changes': { contents: 'read' },
'.github/workflows/node-server-tests.yml#desktop_template': { contents: 'read' },
'.github/workflows/node-server-tests.yml#linux_glibc217_compat': { contents: 'read' },
'.github/workflows/node-server-tests.yml#linux_glibc_floor': { contents: 'read' },
'.github/workflows/node-server-tests.yml#linux_musl': { contents: 'read' },
'.github/workflows/node-server-tests.yml#linux_glibc_floor': {
actions: 'read',
contents: 'read'
},
'.github/workflows/node-server-tests.yml#linux_musl': { actions: 'read', contents: 'read' },
'.github/workflows/node-server-tests.yml#mobile_web_bundle': { contents: 'read' },
'.github/workflows/node-server-tests.yml#persistence': { contents: 'read' },
'.github/workflows/node-server-tests.yml#persistence': { actions: 'read', contents: 'read' },
'.github/workflows/release-mac-build.yml#build-mac': { actions: 'read', contents: 'write' },
'.github/workflows/release-mac-build.yml#qualification-template': { contents: 'read' },
'.github/workflows/release-mac-build.yml#qualification-template': {
actions: 'read',
contents: 'read'
},
...Object.fromEntries(
[
'changes',
@@ -36,7 +42,9 @@ const EXPECTED_MATRIX = {
'persistence'
].map((job) => [
`.github/workflows/release-mac-build.yml#qualification-template -> .github/workflows/node-server-tests.yml#${job}`,
{ contents: 'read' }
['persistence', 'linux_glibc_floor', 'linux_musl'].includes(job)
? { actions: 'read', contents: 'read' }
: { contents: 'read' }
])
),
[`${RELEASE_WORKFLOW}#build`]: { actions: 'read', contents: 'write' },
@@ -54,7 +62,7 @@ const EXPECTED_MATRIX = {
{
contents: 'read'
},
[`${RELEASE_WORKFLOW}#orcad-template`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#orcad-template`]: { actions: 'read', contents: 'read' },
[`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/node-server-tests.yml#changes`]: {
contents: 'read'
},
@@ -68,9 +76,11 @@ const EXPECTED_MATRIX = {
},
[`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/node-server-tests.yml#linux_glibc_floor`]:
{
actions: 'read',
contents: 'read'
},
[`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/node-server-tests.yml#linux_musl`]: {
actions: 'read',
contents: 'read'
},
[`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/node-server-tests.yml#mobile_web_bundle`]:
@@ -78,6 +88,7 @@ const EXPECTED_MATRIX = {
contents: 'read'
},
[`${RELEASE_WORKFLOW}#orcad-template -> .github/workflows/node-server-tests.yml#persistence`]: {
actions: 'read',
contents: 'read'
},
[`${RELEASE_WORKFLOW}#post-release-e2e`]: { actions: 'write' },
@@ -237,7 +248,11 @@ describe('release-cut token permissions', () => {
expect(tagJobs.map(([jobName]) => jobName).sort()).toEqual(RELEASE_TAG_EXECUTION_JOBS)
for (const [jobName] of tagJobs) {
if (!PUBLISH_TAG_JOBS.has(jobName)) {
expect(matrix[`${RELEASE_WORKFLOW}#${jobName}`]).toEqual({ contents: 'read' })
expect(matrix[`${RELEASE_WORKFLOW}#${jobName}`]).toEqual(
jobName === 'orcad-template'
? { actions: 'read', contents: 'read' }
: { contents: 'read' }
)
}
}
})