Skip to content

Commit da38591

Browse files
committed
Merge branch 'feature/gha-cost-controls' into main
2 parents 0651d60 + 301ed85 commit da38591

6 files changed

Lines changed: 218 additions & 32 deletions

File tree

.github/workflows/README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# GitHub Actions — cost & deploy playbook
2+
3+
Copy these patterns into product monorepos. Goal: keep private-repo
4+
**billable minutes under the free ~2,000/month** (Windows ×2, macOS ×10).
5+
6+
## Defaults every deploy workflow should have
7+
8+
| Control | Why |
9+
|---------|-----|
10+
| `paths:` on `push` | Unrelated monorepo edits must not rebuild docker |
11+
| Never `packages/**` | Prefer explicit `packages/<name>/**` per consumer |
12+
| No `pull_request` on deploy | PR CI lives in `review.yml`; deploy = main only |
13+
| `concurrency` + `cancel-in-progress: true` | Rapid main pushes: only latest SHA finishes |
14+
| `docker/build-push-action` + `cache-from/to: type=gha,scope=<img>` | Warm builds cut 40–60% of docker time |
15+
| `permissions.actions: write` | Required to write GHA docker cache |
16+
| `timeout-minutes` on light jobs | Bound runaway runners (e.g. release-please) |
17+
18+
## Path fan-out rules
19+
20+
```yaml
21+
# BAD — one package edit rebuilds api + web + worker
22+
paths:
23+
- "packages/**"
24+
25+
# GOOD — each service lists only packages it COPYs / imports
26+
# api/worker (python workspace dep):
27+
paths:
28+
- "apps/api/**"
29+
- "packages/core/**"
30+
# web (bun workspace tokens/i18n only):
31+
paths:
32+
- "apps/web/**"
33+
- "packages/design-tokens/**"
34+
- "packages/i18n/**"
35+
```
36+
37+
If the Dockerfile context is `apps/<svc>` only and does **not** COPY monorepo
38+
`packages/`, do not list packages at all.
39+
40+
## Docker build snippet (canonical)
41+
42+
```yaml
43+
permissions:
44+
contents: read
45+
id-token: write
46+
actions: write
47+
48+
concurrency:
49+
group: deploy-<svc>
50+
cancel-in-progress: true
51+
52+
steps:
53+
- uses: docker/setup-buildx-action@v4
54+
- uses: docker/build-push-action@v6
55+
with:
56+
context: apps/api # or monorepo root if Dockerfile needs packages/*
57+
file: apps/api/Dockerfile
58+
push: true
59+
platforms: linux/amd64
60+
tags: ${{ env.IMAGE }}:${{ github.sha }}
61+
cache-from: type=gha,scope=<unique-image-name>
62+
cache-to: type=gha,mode=max,scope=<unique-image-name>
63+
```
64+
65+
Use a **unique `scope` per image** so api/web/worker caches do not clobber each other.
66+
67+
## release-please
68+
69+
```yaml
70+
on:
71+
push:
72+
branches: [main]
73+
paths-ignore:
74+
- "**.md"
75+
- "docs/**"
76+
concurrency:
77+
group: release-please
78+
cancel-in-progress: true
79+
```
80+
81+
## Measure usage
82+
83+
```bash
84+
# Org monthly Actions usage (enhanced billing)
85+
gh api "orgs/<org>/settings/billing/usage?year=YYYY&month=M" \
86+
| jq '[.usageItems[]
87+
| select(.product=="actions" and .unitType=="Minutes")
88+
| {repo: .repositoryName, sku, qty: .quantity}]'
89+
```
90+
91+
Public repos usually do not count against the free private minutes quota;
92+
private Linux minutes count 1×.
93+
94+
## What not to do
95+
96+
- Auto-deploy on every PR (test in `review.yml`, deploy on main/tag only)
97+
- macOS/Windows matrix on every PR (10× / 2× billable)
98+
- `cancel-in-progress: false` on deploy when agents push many main commits
99+
- Bare `docker build && docker push` without GHA cache in monorepos that ship often

.github/workflows/deploy-api.yml

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
name: Deploy API
22

3+
# Template cost controls (Actions minutes) — copy into product repos:
4+
# - path filters so unrelated monorepo edits do not rebuild
5+
# - concurrency cancel-in-progress: only the latest SHA deploys on main
6+
# - docker/build-push-action GHA layer cache (scope per image)
7+
# - PR CI lives in review.yml; this workflow is main/deploy only
8+
39
on:
410
push:
511
branches: [main]
@@ -8,12 +14,22 @@ on:
814
- ".github/workflows/deploy-api.yml"
915
workflow_dispatch:
1016

17+
concurrency:
18+
group: deploy-api
19+
cancel-in-progress: true
20+
21+
permissions:
22+
contents: read
23+
id-token: write
24+
actions: write # docker GHA cache (cache-to: type=gha)
25+
1126
env:
1227
PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
1328
REGION: ${{ vars.GCP_REGION }}
1429
SERVICE_NAME: fullstack-starter-api
1530
WORKLOAD_IDENTITY_PROVIDER: ${{ vars.WORKLOAD_IDENTITY_PROVIDER }}
1631
SERVICE_ACCOUNT: ${{ vars.GCP_SERVICE_ACCOUNT }}
32+
IMAGE: ${{ vars.GCP_REGION }}-docker.pkg.dev/${{ vars.GCP_PROJECT_ID }}/fullstack-starter-images/api
1733

1834
jobs:
1935
test:
@@ -29,6 +45,8 @@ jobs:
2945
uses: astral-sh/setup-uv@v8.2.0
3046
with:
3147
version: "latest"
48+
enable-cache: true
49+
cache-dependency-glob: "apps/api/uv.lock"
3250

3351
- name: Set up Python
3452
run: uv python install 3.12
@@ -48,9 +66,6 @@ jobs:
4866
build-and-deploy:
4967
needs: test
5068
runs-on: ubuntu-latest
51-
permissions:
52-
contents: read
53-
id-token: write
5469

5570
steps:
5671
- uses: actions/checkout@v6
@@ -65,21 +80,26 @@ jobs:
6580
uses: google-github-actions/setup-gcloud@v3
6681

6782
- name: Configure Docker
68-
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev
83+
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet
84+
85+
- name: Set up Docker Buildx
86+
uses: docker/setup-buildx-action@v4
6987

7088
- name: Build and push image
71-
run: |
72-
IMAGE="${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/fullstack-starter-images/api:${{ github.sha }}"
73-
docker build -t $IMAGE apps/api
74-
docker push $IMAGE
75-
echo "IMAGE=$IMAGE" >> $GITHUB_ENV
89+
uses: docker/build-push-action@v6
90+
with:
91+
context: apps/api
92+
push: true
93+
tags: ${{ env.IMAGE }}:${{ github.sha }}
94+
cache-from: type=gha,scope=fullstack-starter-api
95+
cache-to: type=gha,mode=max,scope=fullstack-starter-api
7696

7797
- name: Deploy to Cloud Run
7898
uses: google-github-actions/deploy-cloudrun@v3
7999
with:
80100
service: ${{ env.SERVICE_NAME }}
81101
region: ${{ env.REGION }}
82-
image: ${{ env.IMAGE }}
102+
image: ${{ env.IMAGE }}:${{ github.sha }}
83103
flags: |
84104
--min-instances=0
85105
--max-instances=10

.github/workflows/deploy-mobile.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
name: Mobile CI
22

3+
# Cost controls (Actions minutes):
4+
# - path filters so non-mobile monorepo edits skip this workflow
5+
# - concurrency cancel-in-progress (macos is 10x billable — cancel stale)
6+
# - PR runs analyze/test only; build-android/ios/deploy stay main-push only
7+
# - prefer ubuntu for analyze; never matrix macos on every PR
8+
39
on:
410
push:
511
branches: [main]
612
paths:
713
- "apps/mobile/**"
8-
- ".github/workflows/mobile-ci.yml"
14+
- ".github/workflows/deploy-mobile.yml"
915
pull_request:
1016
branches: [main]
1117
paths:
1218
- "apps/mobile/**"
1319
workflow_dispatch:
1420

21+
concurrency:
22+
group: mobile-ci-${{ github.workflow }}-${{ github.ref }}
23+
cancel-in-progress: true
24+
1525
env:
1626
FLUTTER_VERSION: "3.38.5"
1727
RUBY_VERSION: "3.3"

.github/workflows/deploy-web.yml

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
name: Deploy Web
22

3+
# Template cost controls (Actions minutes) — copy into product repos:
4+
# - path filters so unrelated monorepo edits do not rebuild
5+
# - concurrency cancel-in-progress: only the latest SHA deploys on main
6+
# - docker/build-push-action GHA layer cache (scope per image)
7+
# - PR CI lives in review.yml; this workflow is main/deploy only
8+
# - if web copies monorepo packages in its Dockerfile, add those package
9+
# paths explicitly (e.g. packages/design-tokens/**) — never packages/**
10+
311
on:
412
push:
513
branches: [main]
@@ -8,12 +16,22 @@ on:
816
- ".github/workflows/deploy-web.yml"
917
workflow_dispatch:
1018

19+
concurrency:
20+
group: deploy-web
21+
cancel-in-progress: true
22+
23+
permissions:
24+
contents: read
25+
id-token: write
26+
actions: write # docker GHA cache (cache-to: type=gha)
27+
1128
env:
1229
PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
1330
REGION: ${{ vars.GCP_REGION }}
1431
SERVICE_NAME: fullstack-starter-web
1532
WORKLOAD_IDENTITY_PROVIDER: ${{ vars.WORKLOAD_IDENTITY_PROVIDER }}
1633
SERVICE_ACCOUNT: ${{ vars.GCP_SERVICE_ACCOUNT }}
34+
IMAGE: ${{ vars.GCP_REGION }}-docker.pkg.dev/${{ vars.GCP_PROJECT_ID }}/fullstack-starter-images/web
1735

1836
jobs:
1937
test:
@@ -45,9 +63,6 @@ jobs:
4563
build-and-deploy:
4664
needs: test
4765
runs-on: ubuntu-latest
48-
permissions:
49-
contents: read
50-
id-token: write
5166

5267
steps:
5368
- uses: actions/checkout@v6
@@ -62,22 +77,28 @@ jobs:
6277
uses: google-github-actions/setup-gcloud@v3
6378

6479
- name: Configure Docker
65-
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev
80+
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet
81+
82+
- name: Set up Docker Buildx
83+
uses: docker/setup-buildx-action@v4
6684

6785
- name: Build and push image
68-
run: |
69-
IMAGE="${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/fullstack-starter-images/web:${{ github.sha }}"
70-
docker build -t $IMAGE apps/web \
71-
--build-arg NEXT_PUBLIC_API_URL=${{ vars.API_URL }}
72-
docker push $IMAGE
73-
echo "IMAGE=$IMAGE" >> $GITHUB_ENV
86+
uses: docker/build-push-action@v6
87+
with:
88+
context: apps/web
89+
push: true
90+
tags: ${{ env.IMAGE }}:${{ github.sha }}
91+
build-args: |
92+
NEXT_PUBLIC_API_URL=${{ vars.API_URL }}
93+
cache-from: type=gha,scope=fullstack-starter-web
94+
cache-to: type=gha,mode=max,scope=fullstack-starter-web
7495

7596
- name: Deploy to Cloud Run
7697
uses: google-github-actions/deploy-cloudrun@v3
7798
with:
7899
service: ${{ env.SERVICE_NAME }}
79100
region: ${{ env.REGION }}
80-
image: ${{ env.IMAGE }}
101+
image: ${{ env.IMAGE }}:${{ github.sha }}
81102
flags: |
82103
--min-instances=0
83104
--max-instances=10

.github/workflows/deploy-worker.yml

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
name: Deploy Worker
22

3+
# Template cost controls (Actions minutes) — copy into product repos:
4+
# - path filters so unrelated monorepo edits do not rebuild
5+
# - concurrency cancel-in-progress: only the latest SHA deploys on main
6+
# - docker/build-push-action GHA layer cache (scope per image)
7+
# - PR CI lives in review.yml; this workflow is main/deploy only
8+
39
on:
410
push:
511
branches: [main]
@@ -8,12 +14,22 @@ on:
814
- ".github/workflows/deploy-worker.yml"
915
workflow_dispatch:
1016

17+
concurrency:
18+
group: deploy-worker
19+
cancel-in-progress: true
20+
21+
permissions:
22+
contents: read
23+
id-token: write
24+
actions: write # docker GHA cache (cache-to: type=gha)
25+
1126
env:
1227
PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
1328
REGION: ${{ vars.GCP_REGION }}
1429
SERVICE_NAME: fullstack-starter-worker
1530
WORKLOAD_IDENTITY_PROVIDER: ${{ vars.WORKLOAD_IDENTITY_PROVIDER }}
1631
SERVICE_ACCOUNT: ${{ vars.GCP_SERVICE_ACCOUNT }}
32+
IMAGE: ${{ vars.GCP_REGION }}-docker.pkg.dev/${{ vars.GCP_PROJECT_ID }}/fullstack-starter-images/worker
1733

1834
jobs:
1935
test:
@@ -29,6 +45,8 @@ jobs:
2945
uses: astral-sh/setup-uv@v8.2.0
3046
with:
3147
version: "latest"
48+
enable-cache: true
49+
cache-dependency-glob: "apps/worker/uv.lock"
3250

3351
- name: Set up Python
3452
run: uv python install 3.12
@@ -48,9 +66,6 @@ jobs:
4866
build-and-deploy:
4967
needs: test
5068
runs-on: ubuntu-latest
51-
permissions:
52-
contents: read
53-
id-token: write
5469

5570
steps:
5671
- uses: actions/checkout@v6
@@ -65,21 +80,26 @@ jobs:
6580
uses: google-github-actions/setup-gcloud@v3
6681

6782
- name: Configure Docker
68-
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev
83+
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet
84+
85+
- name: Set up Docker Buildx
86+
uses: docker/setup-buildx-action@v4
6987

7088
- name: Build and push image
71-
run: |
72-
IMAGE="${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/fullstack-starter-images/worker:${{ github.sha }}"
73-
docker build -t $IMAGE apps/worker
74-
docker push $IMAGE
75-
echo "IMAGE=$IMAGE" >> $GITHUB_ENV
89+
uses: docker/build-push-action@v6
90+
with:
91+
context: apps/worker
92+
push: true
93+
tags: ${{ env.IMAGE }}:${{ github.sha }}
94+
cache-from: type=gha,scope=fullstack-starter-worker
95+
cache-to: type=gha,mode=max,scope=fullstack-starter-worker
7696

7797
- name: Deploy to Cloud Run
7898
uses: google-github-actions/deploy-cloudrun@v3
7999
with:
80100
service: ${{ env.SERVICE_NAME }}
81101
region: ${{ env.REGION }}
82-
image: ${{ env.IMAGE }}
102+
image: ${{ env.IMAGE }}:${{ github.sha }}
83103
flags: |
84104
--min-instances=0
85105
--max-instances=5

0 commit comments

Comments
 (0)