diff --git a/.github/workflows/docker-gradle-build-push.yml b/.github/workflows/docker-gradle-build-push.yml index 3255aaa..f0270cc 100644 --- a/.github/workflows/docker-gradle-build-push.yml +++ b/.github/workflows/docker-gradle-build-push.yml @@ -128,10 +128,16 @@ jobs: org.opencontainers.image.vendor=Grounds org.opencontainers.image.ref.name=${{ github.sha }} - # Repository-scoped GHA cache survives ephemeral ARC runners without - # exposing intermediate build layers in a potentially public GHCR - # package. PRs never write cache or image tags. Cache mounts (e.g. Gradle - # dependency mounts) are not exported; the first builds may be colder. + # The GHA layer cache is used on GitHub-hosted runners only. From the + # self-hosted ARC runners it downloads at tens of KB/s: plugin-match + # 0.9.0 (2026-09-29) spent 20+ minutes pulling one 93 MB cached layer at + # ~60 KB/s for a build that takes a few minutes uncached — and the + # 60-minute ARC timeout below exists for the same symptom. On ARC the + # build runs without a layer cache instead. On hosted runners the + # repository-scoped cache still saves time without exposing intermediate + # layers in a potentially public GHCR package. PRs never write cache or + # image tags. Cache mounts (e.g. Gradle dependency mounts) are not + # exported either way. - name: Build image (pull requests) if: github.event_name == 'pull_request' uses: docker/build-push-action@v7 @@ -142,7 +148,7 @@ jobs: platforms: linux/amd64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=${{ github.repository }}-amd64 + cache-from: ${{ runner.environment == 'github-hosted' && format('type=gha,scope={0}-amd64', github.repository) || '' }} build-args: | GITHUB_USER=${{ github.actor }} secrets: | @@ -158,8 +164,8 @@ jobs: platforms: linux/amd64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=${{ github.repository }}-amd64 - cache-to: type=gha,scope=${{ github.repository }}-amd64,mode=max + cache-from: ${{ runner.environment == 'github-hosted' && format('type=gha,scope={0}-amd64', github.repository) || '' }} + cache-to: ${{ runner.environment == 'github-hosted' && format('type=gha,scope={0}-amd64,mode=max', github.repository) || '' }} build-args: | GITHUB_USER=${{ github.actor }} secrets: | diff --git a/tests/test_docker_workflow.py b/tests/test_docker_workflow.py index a88cb98..85cc646 100644 --- a/tests/test_docker_workflow.py +++ b/tests/test_docker_workflow.py @@ -13,9 +13,11 @@ def resolve(value, context): return value def expression(match): source = match.group(1).strip() - source = re.sub(r'\b(?:github|inputs|steps|env)\.[\w.]+', lambda field: repr(context[field[0]]), source) + source = re.sub(r'\b(?:github|inputs|steps|env|runner)\.[\w.]+', lambda field: repr(context[field[0]]), source) + # GitHub's format('{0}', x); evaluated as Python's str.format. + source = re.sub(r'\bformat\(', 'str.format(', source) source = source.replace('&&', ' and ').replace('||', ' or ') - return str(eval('(' + source + ')', {'__builtins__': {}}, {})) + return str(eval('(' + source + ')', {'__builtins__': {}, 'str': str}, {})) return re.sub(r'\$\{\{(.*?)\}\}', expression, value, flags=re.DOTALL) @@ -49,6 +51,8 @@ def test_build_and_cache_contract_for_public_and_private_events(self): 'github.event_name': event, 'github.repository': 'groundsgg/plugin-notifications', 'inputs.runner': '', + # Public repos land on GitHub's runners, private ones on their ARC set. + 'runner.environment': 'github-hosted' if visibility == 'public' else 'self-hosted', } active = [step for step in job['steps'] if resolve('${{ ' + step['if'] + ' }}' if 'if' in step else True, context) not in ['False', False]] builders = [step for step in active if 'setup-buildx-action' in step.get('uses', '')] @@ -60,17 +64,22 @@ def test_build_and_cache_contract_for_public_and_private_events(self): options = step['with'] self.assertEqual(options['platforms'], 'linux/amd64') self.assertIn('github_token=', options['secrets']) - self.assertEqual(resolve(options['cache-from'], context), 'type=gha,scope=groundsgg/plugin-notifications-amd64') + # The GHA layer cache downloads at tens of KB/s on ARC; hosted runners only. + expected_from = 'type=gha,scope=groundsgg/plugin-notifications-amd64' if visibility == 'public' else '' + self.assertEqual(resolve(options['cache-from'], context), expected_from) pushes = [step for step in builds if step['with'].get('push') is True] - cache_writes = [step for step in builds if step['with'].get('cache-to')] + cache_writes = [step for step in builds if resolve(step['with'].get('cache-to', ''), context)] if event == 'pull_request': self.assertEqual(pushes, []) self.assertEqual(cache_writes, []) self.assertTrue(any(step['with'].get('load') for step in builds)) else: self.assertEqual(len(pushes), 1) - self.assertEqual(len(cache_writes), 1) - self.assertIn('mode=max', cache_writes[0]['with']['cache-to']) + if visibility == 'public': + self.assertEqual(len(cache_writes), 1) + self.assertIn('mode=max', resolve(cache_writes[0]['with']['cache-to'], context)) + else: + self.assertEqual(cache_writes, [], 'ARC builds must not export to the GHA cache') expected_runner = 'ubuntu-24.04' if visibility == 'public' else 'plugin-notifications' self.assertEqual(resolve(job['runs-on'], context).strip(), expected_runner) expected_budget = 30 if visibility == 'public' else 60