From ea2015b15d3d1b59e06e5a342aac3ee47c1aa528 Mon Sep 17 00:00:00 2001 From: JSv4 Date: Fri, 11 Sep 2026 23:46:25 -0500 Subject: [PATCH] ci: publish main images for automatic production releases --- .github/PRODUCTION_RELEASES.md | 18 +++ .github/scripts/wait-for-production-ci.py | 72 +++++++++ .github/workflows/production-release.yml | 173 ++++++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 .github/PRODUCTION_RELEASES.md create mode 100644 .github/scripts/wait-for-production-ci.py create mode 100644 .github/workflows/production-release.yml diff --git a/.github/PRODUCTION_RELEASES.md b/.github/PRODUCTION_RELEASES.md new file mode 100644 index 0000000..08d70db --- /dev/null +++ b/.github/PRODUCTION_RELEASES.md @@ -0,0 +1,18 @@ +# Production image releases + +`workflows/production-release.yml` builds images on main, waits for successful +repository CI on the same commit, and requests deployment through the +infrastructure-owned Cloud Build release pipeline. Production releases require +`PRODUCTION_RELEASES_ENABLED=true`; forks remain disabled by default. PR builds +validate images without authenticating to Google Cloud or publishing them. + +Repository variables supplied by the infrastructure setup are +`PRODUCTION_PROJECT`, `PRODUCTION_IMAGE_REGISTRY`, `PRODUCTION_RELEASE_TOPIC`, +`PRODUCTION_WIF_PROVIDER`, and `PRODUCTION_PUBLISHER_ACCOUNT`. These are identifiers, +not secret keys. Authentication uses short-lived GitHub identity federation. + +The final GitHub job reports that the release was queued. Cloud Build contains +the final rollout result and logs. Set `PRODUCTION_RELEASES_ENABLED=false` to +stop new requests; freeze/cancel Cloud Build releases too for an immediate stop. +Workflow/helper definitions are maintained by the infrastructure repository's +`releases/services.json` and `scripts/releases/render-workflow.py`. diff --git a/.github/scripts/wait-for-production-ci.py b/.github/scripts/wait-for-production-ci.py new file mode 100644 index 0000000..1370668 --- /dev/null +++ b/.github/scripts/wait-for-production-ci.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Wait for successful push CI for the exact commit being released (no secrets).""" + +import json +import os +import time +import urllib.parse +import urllib.request +from typing import TypedDict + + +class WorkflowRun(TypedDict): + head_sha: str + event: str + head_branch: str + run_number: int + run_attempt: int + status: str + conclusion: str + html_url: str + + +def check_runs(runs: list[WorkflowRun], sha: str) -> bool: + # Reruns can supersede a prior failure; the newest run is authoritative. + matches = [ + r + for r in runs + if r["head_sha"] == sha and r["event"] == "push" and r["head_branch"] == "main" + ] + if not matches: + return False + run = max(matches, key=lambda r: (r["run_number"], r.get("run_attempt", 1))) + if run["status"] != "completed": + return False + if run["conclusion"] != "success": + raise RuntimeError("Required main CI failed: " + run["html_url"]) + return True + + +def main() -> None: + repository = os.environ["GITHUB_REPOSITORY"] + sha = os.environ["GITHUB_SHA"] + workflows = json.loads(os.environ["REQUIRED_WORKFLOWS"]) + deadline = time.monotonic() + 5400 + while time.monotonic() < deadline: + complete = True + for workflow in workflows: + path = ( + f"repos/{repository}/actions/workflows/{workflow}/runs?" + + urllib.parse.urlencode({"head_sha": sha, "event": "push", "per_page": 100}) + ) + request = urllib.request.Request( + "https://api.github.com/" + path, + headers={ + "Authorization": "Bearer " + os.environ["GH_TOKEN"], + "Accept": "application/vnd.github+json", + }, + ) + # The API origin and HTTPS scheme above are fixed, not caller inputs. + with urllib.request.urlopen(request, timeout=45) as response: # noqa: S310 + runs = json.load(response)["workflow_runs"] + complete = check_runs(runs, sha) and complete + if complete: + print("Required CI passed for " + sha, flush=True) + return + print("Waiting for main CI on " + sha, flush=True) + time.sleep(30) + raise TimeoutError("Required CI did not succeed within 90 minutes; no release requested") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/production-release.yml b/.github/workflows/production-release.yml new file mode 100644 index 0000000..f06c7c0 --- /dev/null +++ b/.github/workflows/production-release.yml @@ -0,0 +1,173 @@ +# Maintained from the infra repository's releases/services.json and render-workflow.py. +name: Production image release +'on': + push: + branches: + - main + paths-ignore: + - docs/** + - changelog.d/** + - '**/*.md' + pull_request: + paths: + - .github/workflows/production-release.yml + - .github/scripts/wait-for-production-ci.py + - Dockerfile +permissions: + contents: read +concurrency: + group: production-release-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false +jobs: + checks: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.PRODUCTION_RELEASES_ENABLED == 'true' + runs-on: ubuntu-latest + timeout-minutes: 95 + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Wait for successful main CI on this exact commit + env: + GH_TOKEN: ${{ github.token }} + REQUIRED_WORKFLOWS: '["ci.yml"]' + run: python3 .github/scripts/wait-for-production-ci.py + build: + if: github.event_name == 'pull_request' || vars.PRODUCTION_RELEASES_ENABLED == 'true' + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + include: + - image: privacy-filter + dockerfile: Dockerfile + context: . + build_args: '' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Free space for production images + run: | + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL + df -h / + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + - name: Build image before authenticating to Google Cloud + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + load: true + push: false + platforms: linux/amd64 + provenance: false + tags: release-image:${{ github.sha }}-${{ matrix.image }} + labels: | + org.opencontainers.image.revision=${{ github.sha }} + legal.opensource.release-component=${{ matrix.image }} + build-args: ${{ matrix.build_args }} + cache-from: type=gha,scope=production-${{ matrix.image }} + cache-to: type=gha,mode=max,scope=production-${{ matrix.image }} + - name: Enforce existing Django image size budget + if: matrix.image == 'django' + env: + IMAGE: release-image:${{ github.sha }}-${{ matrix.image }} + run: | + size=$(docker image inspect --format='{{.Size}}' "$IMAGE") + test "$size" -le 3221225472 || { echo 'Django image exceeds the existing 3 GiB release budget'; exit 1; } + - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 + with: + workload_identity_provider: ${{ vars.PRODUCTION_WIF_PROVIDER }} + service_account: ${{ vars.PRODUCTION_PUBLISHER_ACCOUNT }} + project_id: ${{ vars.PRODUCTION_PROJECT }} + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.PRODUCTION_RELEASES_ENABLED == + 'true' + - uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.PRODUCTION_RELEASES_ENABLED == + 'true' + - name: Publish immutable image and record its digest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.PRODUCTION_RELEASES_ENABLED == + 'true' + env: + REGISTRY: ${{ vars.PRODUCTION_IMAGE_REGISTRY }} + IMAGE: ${{ matrix.image }} + run: | + set -euo pipefail + test -n "$REGISTRY" + gcloud auth configure-docker "${REGISTRY%%/*}" --quiet + target="$REGISTRY/$IMAGE:$GITHUB_SHA-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + docker tag "release-image:$GITHUB_SHA-$IMAGE" "$target" + docker push "$target" + mkdir -p release-digests + TARGET="$target" python3 - <<'PY' + import json, os, re, subprocess + info = json.loads(subprocess.run(['docker', 'image', 'inspect', os.environ['TARGET']], check=True, capture_output=True, text=True).stdout)[0] + prefix = os.environ['REGISTRY'] + '/' + os.environ['IMAGE'] + '@' + digest = next(d[len(prefix):] for d in info['RepoDigests'] if d.startswith(prefix)) + assert re.fullmatch(r'sha256:[0-9a-f]{64}', digest) + with open('release-digests/' + os.environ['IMAGE'] + '.json', 'w') as f: + json.dump({'commit': os.environ['GITHUB_SHA'], 'image': os.environ['IMAGE'], 'digest': digest}, f) + PY + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.PRODUCTION_RELEASES_ENABLED == + 'true' + with: + name: release-image-${{ matrix.image }} + path: release-digests/*.json + if-no-files-found: error + retention-days: 7 + release: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.PRODUCTION_RELEASES_ENABLED == 'true' + needs: + - checks + - build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + actions: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + pattern: release-image-* + merge-multiple: true + path: release-digests + - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 + with: + workload_identity_provider: ${{ vars.PRODUCTION_WIF_PROVIDER }} + service_account: ${{ vars.PRODUCTION_PUBLISHER_ACCOUNT }} + project_id: ${{ vars.PRODUCTION_PROJECT }} + - uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db + - name: Request production release after all builds and CI succeed + env: + PROJECT: ${{ vars.PRODUCTION_PROJECT }} + TOPIC: ${{ vars.PRODUCTION_RELEASE_TOPIC }} + EXPECTED_IMAGES: '["privacy-filter"]' + run: | + python3 - <<'PY' + import json, os, pathlib, re, subprocess + images = {} + for path in pathlib.Path('release-digests').glob('*.json'): + record = json.loads(path.read_text()) + assert record['commit'] == os.environ['GITHUB_SHA'] + assert record['image'] not in images + assert re.fullmatch(r'sha256:[0-9a-f]{64}', record['digest']) + images[record['image']] = record['digest'] + assert set(images) == set(json.loads(os.environ['EXPECTED_IMAGES'])) + assert os.environ['PROJECT'] and os.environ['TOPIC'] + release = {'version': 1, 'repository': os.environ['GITHUB_REPOSITORY'], 'commit': os.environ['GITHUB_SHA'], 'images': images} + message = json.dumps({'schema': '1', 'release': json.dumps(release, separators=(',', ':'))}) + subprocess.run(['gcloud', 'pubsub', 'topics', 'publish', os.environ['TOPIC'], '--project=' + os.environ['PROJECT'], '--message=' + message], check=True) + print('Release queued in Cloud Build; production rollout status is recorded there.') + PY