From 86d3a5602b3ccfacbd4caecedd3d914fd5d1a017 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Mon, 19 Oct 2026 16:30:27 +0200 Subject: [PATCH] ci(docker): stop uploading workflow artifacts from image builds Private repositories pay for artifact storage out of the org's Actions budget. Once it is spent, every upload fails: game-survivalgames run 39265980480 pushed its image and then went red in "Generate SBOM", whose anchore/sbom-action uploads the SPDX file as an artifact by default. - docker-gradle-build-push: drop the anchore SBOM scan and the sbom-* upload. The push build now attaches an SPDX SBOM attestation to the image in GHCR (`sbom: true`), next to the provenance attestation it already had. Nothing downloaded the sbom-* artifacts. - docker-gradle-build-push, platform-test-preview: set DOCKER_BUILD_RECORD_UPLOAD=false so build-push-action no longer uploads the .dockerbuild record. The build summary stays. - Contract tests: assert no artifact uploads, and bring the runner assertions up to date with the RunsOn routing from #37, which left the contract job failing on main. --- .../workflows/docker-gradle-build-push.yml | 29 ++++++-------- .github/workflows/platform-test-preview.yml | 4 ++ tests/test_docker_workflow.py | 38 +++++++++++++++---- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/.github/workflows/docker-gradle-build-push.yml b/.github/workflows/docker-gradle-build-push.yml index 1e639af..893da1a 100644 --- a/.github/workflows/docker-gradle-build-push.yml +++ b/.github/workflows/docker-gradle-build-push.yml @@ -56,6 +56,13 @@ jobs: && 60 || 30 }} env: TAG_TYPE: ${{ github.ref_type == 'tag' && 'release' || 'edge' }} + # No workflow artifacts from this job. Artifact storage is metered for + # private repositories, and once the org's Actions budget is spent every + # upload fails, which turned runs red after the image was already pushed + # (game-survivalgames, 2026-10-19). The build summary stays; only the + # build record upload is off. The SBOM travels with the image instead, + # as a BuildKit attestation (`sbom: true` below). + DOCKER_BUILD_RECORD_UPLOAD: false steps: # RunsOn (AWS) runners: routes the Actions cache, and with it the Buildx # `type=gha` layer cache, to RunsOn's S3-backed Magic Cache. Needs @@ -218,25 +225,13 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: ${{ steps.layer_cache.outputs.from }} cache-to: ${{ steps.layer_cache.outputs.to }} + # SPDX SBOM as an attestation next to the provenance one, stored in + # GHCR with the image: `docker buildx imagetools inspect + # --format '{{ json .SBOM }}'`. Replaces the separate syft scan whose + # workflow artifact upload failed whenever artifact storage was out. + sbom: true build-args: | GITHUB_USER=${{ github.actor }} secrets: | github_token=${{ secrets.PACKAGES_TOKEN || secrets.GITHUB_TOKEN }} secret-files: ${{ steps.mirror.outputs.init-script && format('gradle_init={0}', steps.mirror.outputs.init-script) || '' }} - - - name: Generate SBOM - if: github.event_name != 'pull_request' - uses: anchore/sbom-action@v0 - with: - image: ${{ fromJSON(steps.meta.outputs.json).tags[0] }} - format: spdx-json - output-file: sbom.spdx.json - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload SBOM - if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v7 - with: - name: sbom-${{ env.TAG_TYPE }} - path: sbom.spdx.json - retention-days: 30 diff --git a/.github/workflows/platform-test-preview.yml b/.github/workflows/platform-test-preview.yml index 61b9ee8..624d647 100644 --- a/.github/workflows/platform-test-preview.yml +++ b/.github/workflows/platform-test-preview.yml @@ -93,6 +93,10 @@ jobs: timeout-minutes: 30 outputs: image-tag: ${{ steps.tag.outputs.value }} + # No build record artifact: artifact storage is metered for private + # repositories and uploads fail once the org's Actions budget is spent. + env: + DOCKER_BUILD_RECORD_UPLOAD: false permissions: contents: read packages: write diff --git a/tests/test_docker_workflow.py b/tests/test_docker_workflow.py index b378d0a..e2e5b45 100644 --- a/tests/test_docker_workflow.py +++ b/tests/test_docker_workflow.py @@ -17,7 +17,9 @@ def expression(match): # 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__': {}, 'str': str}, {})) + source = re.sub(r'\bstartsWith\(', 'starts_with(', source) + starts_with = lambda text, prefix: str(text).startswith(prefix) + return str(eval('(' + source + ')', {'__builtins__': {}, 'str': str, 'starts_with': starts_with}, {})) return re.sub(r'\$\{\{(.*?)\}\}', expression, value, flags=re.DOTALL) @@ -40,6 +42,19 @@ def test_preview_builder_retains_signing_and_pr_isolated_cache(self): self.assertIn('steps.build.outputs.digest', signing['run']) self.assertEqual(build['if'], "github.event.action != 'closed'") + def test_image_builds_upload_no_workflow_artifacts(self): + """Artifact storage runs out with the Actions budget; a pushed image must not fail on it.""" + for name, job_name in [('docker-gradle-build-push.yml', 'docker-build-push'), ('platform-test-preview.yml', 'build')]: + with self.subTest(workflow=name): + job = yaml.safe_load((WORKFLOW.parent / name).read_text())['jobs'][job_name] + self.assertIs(job['env']['DOCKER_BUILD_RECORD_UPLOAD'], False) + for step in job['steps']: + self.assertNotIn('upload-artifact', step.get('uses', '')) + self.assertNotIn('sbom-action', step.get('uses', '')) + job = yaml.safe_load(WORKFLOW.read_text())['jobs']['docker-build-push'] + push = next(step for step in job['steps'] if step.get('name') == 'Build and push image') + self.assertIs(push['with']['sbom'], True) + def test_build_and_cache_contract_for_public_and_private_events(self): job = yaml.safe_load(WORKFLOW.read_text())['jobs']['docker-build-push'] for visibility in ['public', 'private']: @@ -50,7 +65,10 @@ def test_build_and_cache_contract_for_public_and_private_events(self): 'github.event.repository.name': 'plugin-notifications', 'github.event_name': event, 'github.repository': 'groundsgg/plugin-notifications', + 'github.run_id': 42, 'inputs.runner': '', + 'runner.name': 'runs-on--i-0abc' if visibility == 'private' else 'GitHub Actions 7', + 'runner.environment': 'self-hosted' if visibility == 'private' else 'github-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', '')] @@ -74,8 +92,11 @@ def test_build_and_cache_contract_for_public_and_private_events(self): self.assertEqual(len(pushes), 1) self.assertEqual(len(cache_writes), 1) self.assertEqual(cache_writes[0]['with']['cache-to'], '${{ steps.layer_cache.outputs.to }}') - expected_runner = 'ubuntu-24.04' if visibility == 'public' else 'grounds-runners' - self.assertEqual(resolve(job['runs-on'], context).strip(), expected_runner) + runner = resolve(job['runs-on'], context).strip() + if visibility == 'public': + self.assertEqual(runner, 'ubuntu-24.04') + else: + self.assertTrue(runner.startswith('runs-on=42/'), runner) expected_budget = 30 if visibility == 'public' else 60 self.assertEqual(int(resolve(job['timeout-minutes'], context)), expected_budget) @@ -161,21 +182,21 @@ def test_every_central_workflow_routes_private_repos_to_the_shared_pool(self): jobs = (yaml.safe_load(path.read_text()) or {}).get('jobs', {}) for name, job in jobs.items(): runs_on = job.get('runs-on') - if not isinstance(runs_on, str) or 'grounds-runners' not in runs_on: + if not isinstance(runs_on, str) or 'runs-on=' not in runs_on: continue checked += 1 - base = {'inputs.runner': ''} + base = {'inputs.runner': '', 'github.run_id': 42} with self.subTest(workflow=path.name, job=name): private = resolve(runs_on, {**base, 'github.event.repository.visibility': 'private', 'github.event.repository.name': 'plugin-match'}).strip() - self.assertEqual(private, 'grounds-runners') + self.assertTrue(private.startswith('runs-on=42/'), private) public = resolve(runs_on, {**base, 'github.event.repository.visibility': 'public', 'github.event.repository.name': 'service-maps'}).strip() self.assertTrue(public.startswith('ubuntu-24.04'), public) missing = resolve(runs_on, {**base, 'github.event.repository.visibility': '', 'github.event.repository.name': ''}).strip() self.assertEqual(missing, 'ubuntu-24.04') - self.assertEqual(checked, 9, 'every repo-routed job should target the shared pool') + self.assertEqual(checked, 10, 'every repo-routed job should target RunsOn') def test_gradle_caches_are_used_on_hosted_runners_only(self): """From hbr1 the GHA cache stalled at 0 bytes and crawled at ~1 MB/s (2026-09-30). @@ -196,7 +217,8 @@ def test_gradle_caches_are_used_on_hosted_runners_only(self): def test_runner_override_and_missing_event_payload(self): job = yaml.safe_load(WORKFLOW.read_text())['jobs']['docker-build-push'] - context = {'inputs.runner': '', 'github.event.repository.visibility': '', 'github.event.repository.name': ''} + context = {'inputs.runner': '', 'github.event.repository.visibility': '', 'github.event.repository.name': '', + 'github.run_id': 42} self.assertEqual(resolve(job['runs-on'], context), 'ubuntu-24.04') context['inputs.runner'] = 'custom-amd64' self.assertEqual(resolve(job['runs-on'], context), 'custom-amd64')