From 301ed85355c698bb505d9ca67ff5e550be8d91f5 Mon Sep 17 00:00:00 2001 From: gracefullight Date: Thu, 9 Jul 2026 23:21:48 +1000 Subject: [PATCH] ci(root): bake actions cost-control patterns into template workflows add gha docker cache, cancel concurrency, release-please paths-ignore, and a workflows readme playbook for product monorepos to copy. --- .github/workflows/README.md | 99 ++++++++++++++++++++++++++++ .github/workflows/deploy-api.yml | 40 ++++++++--- .github/workflows/deploy-mobile.yml | 12 +++- .github/workflows/deploy-web.yml | 43 ++++++++---- .github/workflows/deploy-worker.yml | 40 ++++++++--- .github/workflows/release-please.yml | 16 +++++ 6 files changed, 218 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/README.md diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..7c6156d --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,99 @@ +# GitHub Actions — cost & deploy playbook + +Copy these patterns into product monorepos. Goal: keep private-repo +**billable minutes under the free ~2,000/month** (Windows ×2, macOS ×10). + +## Defaults every deploy workflow should have + +| Control | Why | +|---------|-----| +| `paths:` on `push` | Unrelated monorepo edits must not rebuild docker | +| Never `packages/**` | Prefer explicit `packages//**` per consumer | +| No `pull_request` on deploy | PR CI lives in `review.yml`; deploy = main only | +| `concurrency` + `cancel-in-progress: true` | Rapid main pushes: only latest SHA finishes | +| `docker/build-push-action` + `cache-from/to: type=gha,scope=` | Warm builds cut 40–60% of docker time | +| `permissions.actions: write` | Required to write GHA docker cache | +| `timeout-minutes` on light jobs | Bound runaway runners (e.g. release-please) | + +## Path fan-out rules + +```yaml +# BAD — one package edit rebuilds api + web + worker +paths: + - "packages/**" + +# GOOD — each service lists only packages it COPYs / imports +# api/worker (python workspace dep): +paths: + - "apps/api/**" + - "packages/core/**" +# web (bun workspace tokens/i18n only): +paths: + - "apps/web/**" + - "packages/design-tokens/**" + - "packages/i18n/**" +``` + +If the Dockerfile context is `apps/` only and does **not** COPY monorepo +`packages/`, do not list packages at all. + +## Docker build snippet (canonical) + +```yaml +permissions: + contents: read + id-token: write + actions: write + +concurrency: + group: deploy- + cancel-in-progress: true + +steps: + - uses: docker/setup-buildx-action@v4 + - uses: docker/build-push-action@v6 + with: + context: apps/api # or monorepo root if Dockerfile needs packages/* + file: apps/api/Dockerfile + push: true + platforms: linux/amd64 + tags: ${{ env.IMAGE }}:${{ github.sha }} + cache-from: type=gha,scope= + cache-to: type=gha,mode=max,scope= +``` + +Use a **unique `scope` per image** so api/web/worker caches do not clobber each other. + +## release-please + +```yaml +on: + push: + branches: [main] + paths-ignore: + - "**.md" + - "docs/**" +concurrency: + group: release-please + cancel-in-progress: true +``` + +## Measure usage + +```bash +# Org monthly Actions usage (enhanced billing) +gh api "orgs//settings/billing/usage?year=YYYY&month=M" \ + | jq '[.usageItems[] + | select(.product=="actions" and .unitType=="Minutes") + | {repo: .repositoryName, sku, qty: .quantity}]' +``` + +Public repos usually do not count against the free private minutes quota; +private Linux minutes count 1×. + +## What not to do + +- Auto-deploy on every PR (test in `review.yml`, deploy on main/tag only) +- macOS/Windows matrix on every PR (10× / 2× billable) +- `cancel-in-progress: false` on deploy when agents push many main commits +- Bare `docker build && docker push` without GHA cache in monorepos that ship often diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml index 234e8ae..5fdf157 100644 --- a/.github/workflows/deploy-api.yml +++ b/.github/workflows/deploy-api.yml @@ -1,5 +1,11 @@ name: Deploy API +# Template cost controls (Actions minutes) — copy into product repos: +# - path filters so unrelated monorepo edits do not rebuild +# - concurrency cancel-in-progress: only the latest SHA deploys on main +# - docker/build-push-action GHA layer cache (scope per image) +# - PR CI lives in review.yml; this workflow is main/deploy only + on: push: branches: [main] @@ -8,12 +14,22 @@ on: - ".github/workflows/deploy-api.yml" workflow_dispatch: +concurrency: + group: deploy-api + cancel-in-progress: true + +permissions: + contents: read + id-token: write + actions: write # docker GHA cache (cache-to: type=gha) + env: PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} REGION: ${{ vars.GCP_REGION }} SERVICE_NAME: fullstack-starter-api WORKLOAD_IDENTITY_PROVIDER: ${{ vars.WORKLOAD_IDENTITY_PROVIDER }} SERVICE_ACCOUNT: ${{ vars.GCP_SERVICE_ACCOUNT }} + IMAGE: ${{ vars.GCP_REGION }}-docker.pkg.dev/${{ vars.GCP_PROJECT_ID }}/fullstack-starter-images/api jobs: test: @@ -29,6 +45,8 @@ jobs: uses: astral-sh/setup-uv@v8.2.0 with: version: "latest" + enable-cache: true + cache-dependency-glob: "apps/api/uv.lock" - name: Set up Python run: uv python install 3.12 @@ -48,9 +66,6 @@ jobs: build-and-deploy: needs: test runs-on: ubuntu-latest - permissions: - contents: read - id-token: write steps: - uses: actions/checkout@v6 @@ -65,21 +80,26 @@ jobs: uses: google-github-actions/setup-gcloud@v3 - name: Configure Docker - run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev + run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 - name: Build and push image - run: | - IMAGE="${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/fullstack-starter-images/api:${{ github.sha }}" - docker build -t $IMAGE apps/api - docker push $IMAGE - echo "IMAGE=$IMAGE" >> $GITHUB_ENV + uses: docker/build-push-action@v6 + with: + context: apps/api + push: true + tags: ${{ env.IMAGE }}:${{ github.sha }} + cache-from: type=gha,scope=fullstack-starter-api + cache-to: type=gha,mode=max,scope=fullstack-starter-api - name: Deploy to Cloud Run uses: google-github-actions/deploy-cloudrun@v3 with: service: ${{ env.SERVICE_NAME }} region: ${{ env.REGION }} - image: ${{ env.IMAGE }} + image: ${{ env.IMAGE }}:${{ github.sha }} flags: | --min-instances=0 --max-instances=10 diff --git a/.github/workflows/deploy-mobile.yml b/.github/workflows/deploy-mobile.yml index ef84e3c..bce681d 100644 --- a/.github/workflows/deploy-mobile.yml +++ b/.github/workflows/deploy-mobile.yml @@ -1,17 +1,27 @@ name: Mobile CI +# Cost controls (Actions minutes): +# - path filters so non-mobile monorepo edits skip this workflow +# - concurrency cancel-in-progress (macos is 10x billable — cancel stale) +# - PR runs analyze/test only; build-android/ios/deploy stay main-push only +# - prefer ubuntu for analyze; never matrix macos on every PR + on: push: branches: [main] paths: - "apps/mobile/**" - - ".github/workflows/mobile-ci.yml" + - ".github/workflows/deploy-mobile.yml" pull_request: branches: [main] paths: - "apps/mobile/**" workflow_dispatch: +concurrency: + group: mobile-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: FLUTTER_VERSION: "3.38.5" RUBY_VERSION: "3.3" diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 498d796..c14fb88 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -1,5 +1,13 @@ name: Deploy Web +# Template cost controls (Actions minutes) — copy into product repos: +# - path filters so unrelated monorepo edits do not rebuild +# - concurrency cancel-in-progress: only the latest SHA deploys on main +# - docker/build-push-action GHA layer cache (scope per image) +# - PR CI lives in review.yml; this workflow is main/deploy only +# - if web copies monorepo packages in its Dockerfile, add those package +# paths explicitly (e.g. packages/design-tokens/**) — never packages/** + on: push: branches: [main] @@ -8,12 +16,22 @@ on: - ".github/workflows/deploy-web.yml" workflow_dispatch: +concurrency: + group: deploy-web + cancel-in-progress: true + +permissions: + contents: read + id-token: write + actions: write # docker GHA cache (cache-to: type=gha) + env: PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} REGION: ${{ vars.GCP_REGION }} SERVICE_NAME: fullstack-starter-web WORKLOAD_IDENTITY_PROVIDER: ${{ vars.WORKLOAD_IDENTITY_PROVIDER }} SERVICE_ACCOUNT: ${{ vars.GCP_SERVICE_ACCOUNT }} + IMAGE: ${{ vars.GCP_REGION }}-docker.pkg.dev/${{ vars.GCP_PROJECT_ID }}/fullstack-starter-images/web jobs: test: @@ -45,9 +63,6 @@ jobs: build-and-deploy: needs: test runs-on: ubuntu-latest - permissions: - contents: read - id-token: write steps: - uses: actions/checkout@v6 @@ -62,22 +77,28 @@ jobs: uses: google-github-actions/setup-gcloud@v3 - name: Configure Docker - run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev + run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 - name: Build and push image - run: | - IMAGE="${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/fullstack-starter-images/web:${{ github.sha }}" - docker build -t $IMAGE apps/web \ - --build-arg NEXT_PUBLIC_API_URL=${{ vars.API_URL }} - docker push $IMAGE - echo "IMAGE=$IMAGE" >> $GITHUB_ENV + uses: docker/build-push-action@v6 + with: + context: apps/web + push: true + tags: ${{ env.IMAGE }}:${{ github.sha }} + build-args: | + NEXT_PUBLIC_API_URL=${{ vars.API_URL }} + cache-from: type=gha,scope=fullstack-starter-web + cache-to: type=gha,mode=max,scope=fullstack-starter-web - name: Deploy to Cloud Run uses: google-github-actions/deploy-cloudrun@v3 with: service: ${{ env.SERVICE_NAME }} region: ${{ env.REGION }} - image: ${{ env.IMAGE }} + image: ${{ env.IMAGE }}:${{ github.sha }} flags: | --min-instances=0 --max-instances=10 diff --git a/.github/workflows/deploy-worker.yml b/.github/workflows/deploy-worker.yml index 8aa43d0..13630c4 100644 --- a/.github/workflows/deploy-worker.yml +++ b/.github/workflows/deploy-worker.yml @@ -1,5 +1,11 @@ name: Deploy Worker +# Template cost controls (Actions minutes) — copy into product repos: +# - path filters so unrelated monorepo edits do not rebuild +# - concurrency cancel-in-progress: only the latest SHA deploys on main +# - docker/build-push-action GHA layer cache (scope per image) +# - PR CI lives in review.yml; this workflow is main/deploy only + on: push: branches: [main] @@ -8,12 +14,22 @@ on: - ".github/workflows/deploy-worker.yml" workflow_dispatch: +concurrency: + group: deploy-worker + cancel-in-progress: true + +permissions: + contents: read + id-token: write + actions: write # docker GHA cache (cache-to: type=gha) + env: PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} REGION: ${{ vars.GCP_REGION }} SERVICE_NAME: fullstack-starter-worker WORKLOAD_IDENTITY_PROVIDER: ${{ vars.WORKLOAD_IDENTITY_PROVIDER }} SERVICE_ACCOUNT: ${{ vars.GCP_SERVICE_ACCOUNT }} + IMAGE: ${{ vars.GCP_REGION }}-docker.pkg.dev/${{ vars.GCP_PROJECT_ID }}/fullstack-starter-images/worker jobs: test: @@ -29,6 +45,8 @@ jobs: uses: astral-sh/setup-uv@v8.2.0 with: version: "latest" + enable-cache: true + cache-dependency-glob: "apps/worker/uv.lock" - name: Set up Python run: uv python install 3.12 @@ -48,9 +66,6 @@ jobs: build-and-deploy: needs: test runs-on: ubuntu-latest - permissions: - contents: read - id-token: write steps: - uses: actions/checkout@v6 @@ -65,21 +80,26 @@ jobs: uses: google-github-actions/setup-gcloud@v3 - name: Configure Docker - run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev + run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 - name: Build and push image - run: | - IMAGE="${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/fullstack-starter-images/worker:${{ github.sha }}" - docker build -t $IMAGE apps/worker - docker push $IMAGE - echo "IMAGE=$IMAGE" >> $GITHUB_ENV + uses: docker/build-push-action@v6 + with: + context: apps/worker + push: true + tags: ${{ env.IMAGE }}:${{ github.sha }} + cache-from: type=gha,scope=fullstack-starter-worker + cache-to: type=gha,mode=max,scope=fullstack-starter-worker - name: Deploy to Cloud Run uses: google-github-actions/deploy-cloudrun@v3 with: service: ${{ env.SERVICE_NAME }} region: ${{ env.REGION }} - image: ${{ env.IMAGE }} + image: ${{ env.IMAGE }}:${{ github.sha }} flags: | --min-instances=0 --max-instances=5 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 0b7b0eb..0753ddb 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -1,17 +1,33 @@ name: release-please +# Cost controls: skip docs-only / config noise so release-please does not +# burn a runner minute on every docs commit. Conventional-commit product +# changes still re-run it via the remaining paths (or lack of paths-ignore match). + on: push: branches: - main + paths-ignore: + - "**.md" + - "docs/**" + - ".agents/**" + - ".serena/**" + - "LICENSE" + - ".gitignore" permissions: contents: write pull-requests: write +concurrency: + group: release-please + cancel-in-progress: true + jobs: release-please: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: googleapis/release-please-action@v5 with: