From 71cf31bf69766b9c671a53865fcda644ac643fd3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:57:29 -0700 Subject: [PATCH] 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 --- .github/workflows/adhoc-mac-build.yml | 1 + .github/workflows/adhoc-orcad-template.yml | 3 + .github/workflows/daily-mac-build.yml | 1 + .github/workflows/hourly-mac-build.yml | 1 + .github/workflows/node-server-tests.yml | 80 +++++++++-- .github/workflows/release-cut.yml | 1 + .github/workflows/release-mac-build.yml | 1 + ...v-channel-orcad-template-workflow.test.mjs | 2 +- .../scripts/node-server-change-scope.test.mjs | 6 +- .../node-server-native-compat-scope.test.mjs | 5 +- .../node-server-release-parallelism.test.mjs | 130 +++++++++++++++++- .../release-cut-token-permissions.test.mjs | 29 +++- 12 files changed, 225 insertions(+), 35 deletions(-) diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index bf02c3dafdd..77fac7b8a01 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -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 }} diff --git a/.github/workflows/adhoc-orcad-template.yml b/.github/workflows/adhoc-orcad-template.yml index 319fc1175d9..f8fb1ba2469 100644 --- a/.github/workflows/adhoc-orcad-template.yml +++ b/.github/workflows/adhoc-orcad-template.yml @@ -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 diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index ce3989ef88f..c7e15044146 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -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 }} diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index d9035285c5c..062ef68169a 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -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 }} diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index d8a51c98509..8fe84611686 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -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 diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index c5036b64e2f..2bf4f926c85 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -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 }} diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index e9dc02b12a1..beb3dc591c9 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -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 }} diff --git a/config/scripts/dev-channel-orcad-template-workflow.test.mjs b/config/scripts/dev-channel-orcad-template-workflow.test.mjs index c66b07541eb..61e1e10a317 100644 --- a/config/scripts/dev-channel-orcad-template-workflow.test.mjs +++ b/config/scripts/dev-channel-orcad-template-workflow.test.mjs @@ -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) }) diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 5e717890c58..0d93a253e20 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -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'") diff --git a/config/scripts/node-server-native-compat-scope.test.mjs b/config/scripts/node-server-native-compat-scope.test.mjs index 1fe0acb514d..39f9bbe567b 100644 --- a/config/scripts/node-server-native-compat-scope.test.mjs +++ b/config/scripts/node-server-native-compat-scope.test.mjs @@ -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 != ''" diff --git a/config/scripts/node-server-release-parallelism.test.mjs b/config/scripts/node-server-release-parallelism.test.mjs index 480b81f405e..775520df3d2 100644 --- a/config/scripts/node-server-release-parallelism.test.mjs +++ b/config/scripts/node-server-release-parallelism.test.mjs @@ -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()') +}) diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index 663b6d3a9da..1b1dac27e56 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -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' } + ) } } })