Skip to content

Commit b3a0a02

Browse files
committed
Skip S3 deployment on branch builds by default
Uploading ETL outputs to S3 incurs egress fees that, for a full PUDL build, cost more than running the entire ETL. Branch builds currently deploy to both GCS and S3 purely as a test, then delete the staged data again. The nightly build exercises the real S3 deployment every night, so re-testing it on every branch build has little marginal value, while the GCS deployment is free and covers nearly all the same code paths. Branch builds now deploy to GCS but not S3 by default. The build-pudl and deploy-pudl workflow_dispatch forms expose deploy_to_gcs / deploy_to_s3 checkboxes to override this per run, and when neither target is enabled build-pudl skips triggering deploy-pudl entirely (e.g. a build run only to regenerate row counts). Nightly and stable deployments are unchanged and still deploy to both. - DeploymentPlan gains tri-state deploy_to_gcs / deploy_to_s3 overrides and symmetric upload_to_gcs / upload_to_s3 properties: GCS defaults on for every deploy type, S3 defaults on for nightly/stable and off for branch builds. A plan that would deploy nowhere is rejected. - upload_outputs() only builds the fs clients and upload targets for the enabled destinations; _assert_permanent_paths_are_empty tolerates a missing filesystem. - pudl_deploy grows --deploy-gcs/--no-deploy-gcs and --deploy-s3/--no-deploy-s3 flags, threaded through resolve_build. - deploy-pudl.yml and build-pudl.yml expose matching workflow_dispatch inputs; build-pudl passes the resolved values to the batch job, and pudl_batch.sh forwards them to deploy-pudl and gates both trigger_deployment call sites. - Add unit tests and a release notes entry. Closes #5557
1 parent 787aeec commit b3a0a02

7 files changed

Lines changed: 253 additions & 29 deletions

File tree

.github/workflows/build-pudl.yml

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22
name: build-pudl
33
on:
44
workflow_dispatch:
5+
inputs:
6+
deploy_to_gcs:
7+
type: boolean
8+
description: "Deploy build outputs to GCS? (branch builds only)"
9+
required: false
10+
default: true
11+
deploy_to_s3:
12+
type: boolean
13+
description: "Deploy build outputs to S3? (branch builds only; S3 egress fees are large)"
14+
required: false
15+
default: false
516
push:
617
tags:
718
# Run a build when a stable tag is pushed. If a build has already been run
@@ -19,6 +30,10 @@ env:
1930
SKIP_BUILD: "false"
2031
GIT_TAG: ""
2132
BUILD_ID: ""
33+
# Nightly and stable builds always deploy to both targets; branch builds
34+
# (workflow_dispatch) override these from the workflow inputs below.
35+
DEPLOY_TO_GCS: "true"
36+
DEPLOY_TO_S3: "true"
2237

2338
jobs:
2439
build_and_deploy_pudl:
@@ -56,9 +71,13 @@ jobs:
5671
echo "DEPLOYMENT_ENVIRONMENT=production" >> "$GITHUB_ENV"
5772
BUILD_ID_RAW="nightly-$BUILD_ID_SUFFIX"
5873
elif [[ ${{ github.event_name }} == "workflow_dispatch" ]]; then
59-
echo "DEPLOYMENT_ENVIRONMENT=staging" >> "$GITHUB_ENV"
6074
BUILD_ID_RAW="branch-$BUILD_ID_SUFFIX"
61-
echo "GIT_TAG=$BUILD_ID_RAW" >> "$GITHUB_ENV"
75+
{
76+
echo "DEPLOYMENT_ENVIRONMENT=staging"
77+
echo "DEPLOY_TO_GCS=${{ inputs.deploy_to_gcs }}"
78+
echo "DEPLOY_TO_S3=${{ inputs.deploy_to_s3 }}"
79+
echo "GIT_TAG=$BUILD_ID_RAW"
80+
} >> "$GITHUB_ENV"
6281
elif [[ ${{ github.event_name }} == "push" ]]; then
6382
echo "GIT_TAG=${{ github.ref_name }}" >> "$GITHUB_ENV"
6483
echo "DEPLOYMENT_ENVIRONMENT=production" >> "$GITHUB_ENV"
@@ -73,6 +92,8 @@ jobs:
7392
echo "GIT_TAG: $GIT_TAG"
7493
echo "BUILD_ID: $BUILD_ID"
7594
echo "DEPLOYMENT_ENVIRONMENT: $DEPLOYMENT_ENVIRONMENT"
95+
echo "DEPLOY_TO_GCS: $DEPLOY_TO_GCS"
96+
echo "DEPLOY_TO_S3: $DEPLOY_TO_S3"
7697
7798
- name: Tag build
7899
if: ${{ (env.SKIP_BUILD != 'true') && (github.event_name != 'push') }}
@@ -172,6 +193,8 @@ jobs:
172193
--container-env BUILD_ID=${{ env.BUILD_ID }} \
173194
--container-env BUILD_REF=${{ github.ref_name }} \
174195
--container-env DEPLOYMENT_ENVIRONMENT=${{ env.DEPLOYMENT_ENVIRONMENT }} \
196+
--container-env DEPLOY_TO_GCS=${{ env.DEPLOY_TO_GCS }} \
197+
--container-env DEPLOY_TO_S3=${{ env.DEPLOY_TO_S3 }} \
175198
--container-env GCP_BILLING_PROJECT=${{ secrets.GCP_BILLING_PROJECT }} \
176199
--container-env GITHUB_ACTION_TRIGGER=${{ github.event_name }} \
177200
--container-env GIT_TAG=${{ env.GIT_TAG }} \

.github/workflows/deploy-pudl.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ on:
1515
options:
1616
- staging
1717
- production
18+
deploy_to_gcs:
19+
type: boolean
20+
description: "Upload outputs to GCS?"
21+
required: false
22+
default: true
23+
deploy_to_s3:
24+
type: boolean
25+
description: "Upload outputs to S3? (S3 egress fees are large)"
26+
required: false
27+
default: true
1828

1929
jobs:
2030
deploy_pudl:
@@ -170,6 +180,8 @@ jobs:
170180
--container-arg="${{ env.GIT_TAG }}" \
171181
--container-arg="--environment" \
172182
--container-arg="${{ env.DEPLOYMENT_ENVIRONMENT }}" \
183+
--container-arg="${{ inputs.deploy_to_gcs && '--deploy-gcs' || '--no-deploy-gcs' }}" \
184+
--container-arg="${{ inputs.deploy_to_s3 && '--deploy-s3' || '--no-deploy-s3' }}" \
173185
--container-env GITHUB_TOKEN=${{ secrets.PUDL_BOT_PAT }} \
174186
--container-env AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }} \
175187
--container-env AWS_DEFAULT_REGION=${{ secrets.AWS_DEFAULT_REGION }} \

builds/pudl_batch.sh

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,19 @@ function trigger_deployment() {
6262
--repo catalyst-cooperative/pudl \
6363
--ref "${BUILD_REF}" \
6464
-f "git_tag=${GIT_TAG}" \
65-
-f "deployment_environment=${DEPLOYMENT_ENVIRONMENT}" &&
65+
-f "deployment_environment=${DEPLOYMENT_ENVIRONMENT}" \
66+
-f "deploy_to_gcs=${DEPLOY_TO_GCS}" \
67+
-f "deploy_to_s3=${DEPLOY_TO_S3}" &&
6668
set -x
6769
}
6870

71+
function any_deployment_target_enabled() {
72+
# If neither cloud storage target is enabled there's nothing for deploy-pudl
73+
# to do (branch builds don't update git branches, redeploy the viewer, or
74+
# trigger Zenodo), so we skip triggering it entirely.
75+
[[ "${DEPLOY_TO_GCS}" == "true" || "${DEPLOY_TO_S3}" == "true" ]]
76+
}
77+
6978
function stage_emoji() {
7079
local stage_status=$1
7180
if [[ $stage_status == "$STAGE_SKIPPED" ]]; then
@@ -259,6 +268,10 @@ TRIGGER_DEPLOYMENT_DURATION=""
259268

260269
# Set these variables *only* if they are not already set by the container or workflow:
261270
: "${PUDL_GCS_OUTPUT:=gs://builds.catalyst.coop/$BUILD_ID}"
271+
# Nightly/stable builds deploy to both cloud storage targets; branch builds set
272+
# these explicitly via the build-pudl workflow inputs.
273+
: "${DEPLOY_TO_GCS:=true}"
274+
: "${DEPLOY_TO_S3:=true}"
262275
# Keep the nightly Dagster config path repo-relative so the same pixi task commands
263276
# work both locally and inside the nightly build container.
264277
: "${DG_NIGHTLY_CONFIG:=src/pudl/package_data/settings/dg_nightly.yml}"
@@ -270,12 +283,16 @@ trap cleanup_on_exit EXIT
270283

271284
# Check if there are any existing builds associated with the current commit
272285
if pixi run pudl_check_for_build "$GIT_TAG"; then
273-
run_stage TRIGGER_DEPLOYMENT_STATUS TRIGGER_DEPLOYMENT_DURATION trigger_deployment
274-
if any_stage_failed "$TRIGGER_DEPLOYMENT_STATUS"; then
275-
echo "Found successful build, but failed to trigger deployment"
276-
exit 1
286+
if any_deployment_target_enabled; then
287+
run_stage TRIGGER_DEPLOYMENT_STATUS TRIGGER_DEPLOYMENT_DURATION trigger_deployment
288+
if any_stage_failed "$TRIGGER_DEPLOYMENT_STATUS"; then
289+
echo "Found successful build, but failed to trigger deployment"
290+
exit 1
291+
fi
292+
echo "Found a successful build and triggered a deployment"
293+
else
294+
echo "Found a successful build; skipping deployment (no GCS or S3 target enabled)"
277295
fi
278-
echo "Found a successful build and triggered a deployment"
279296
exit 0
280297
fi
281298

@@ -316,7 +333,11 @@ require_stage_success "$DATA_VALIDATION_STATUS"
316333
require_stage_success "$ROW_COUNT_VALIDATION_STATUS"
317334
require_stage_success "$SAVE_OUTPUTS_STATUS"
318335

319-
run_stage TRIGGER_DEPLOYMENT_STATUS TRIGGER_DEPLOYMENT_DURATION trigger_deployment
336+
if any_deployment_target_enabled; then
337+
run_stage TRIGGER_DEPLOYMENT_STATUS TRIGGER_DEPLOYMENT_DURATION trigger_deployment
338+
else
339+
echo "Skipping deployment trigger: neither GCS nor S3 deployment is enabled."
340+
fi
320341

321342
# Notify Zulip about entire pipeline's success or failure;
322343
if any_stage_failed \

docs/release_notes.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,15 @@ Developer Experience
156156
so a shared Cloud Monitoring dashboard can filter resource-usage metrics by
157157
pipeline. VM sizes and the ETL's process and thread parallelism were tuned to
158158
match measured resource usage and stop oversubscribing the CPUs. See :pr:`5545`.
159+
* Branch builds (``build-pudl`` runs triggered via ``workflow_dispatch``) now skip
160+
the S3 deployment by default and only deploy to GCS. S3 egress fees cost more than
161+
a full ETL run, and the nightly build already exercises the real S3 deployment
162+
every night. The ``build-pudl`` and ``deploy-pudl`` workflow-dispatch forms expose
163+
``deploy_to_gcs`` / ``deploy_to_s3`` checkboxes to override this per run, and when
164+
neither target is enabled ``build-pudl`` skips triggering ``deploy-pudl``
165+
altogether (e.g. a build run only to regenerate row counts). Nightly and stable
166+
deployments are unchanged and still deploy to both. See issue:`5557` and PR
167+
:pr:`5558`.
159168
* Fixed several issues with how ``dbt_helper update-tables`` renders ``schema.yml``
160169
(:mod:`pudl.dbt_schema`): long ``description:`` fields are now wrapped into readable
161170
paragraph blocks and strings that need quoting prefer double quotes. This now matches

src/pudl/deploy/pudl.py

Lines changed: 80 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ class DeploymentPlan(BaseModel):
6464

6565
git_tag: str
6666
environment: Literal["staging", "production"]
67+
# Tri-state overrides for the cloud storage upload targets. ``None`` means "use
68+
# the default for this deploy type" (see ``upload_to_gcs``/``upload_to_s3``); an
69+
# explicit bool forces the target on or off regardless of deploy type.
70+
deploy_to_gcs: bool | None = None
71+
deploy_to_s3: bool | None = None
6772

6873
@property
6974
def deploy_type(self) -> DeploymentType:
@@ -79,6 +84,39 @@ def _validate_branch_only_targets_staging(self) -> "DeploymentPlan":
7984
)
8085
return self
8186

87+
@model_validator(mode="after")
88+
def _validate_has_an_upload_target(self) -> "DeploymentPlan":
89+
if not self.upload_to_gcs and not self.upload_to_s3:
90+
raise ValueError(
91+
f"Deployment for git_tag={self.git_tag!r} has neither GCS nor S3 "
92+
"uploads enabled -- there would be nothing to deploy. A build that "
93+
"shouldn't deploy anywhere simply shouldn't trigger deploy-pudl."
94+
)
95+
return self
96+
97+
@property
98+
def upload_to_gcs(self) -> bool:
99+
"""Whether this deployment uploads outputs to GCS.
100+
101+
Defaults to ``True`` for every deploy type -- GCS has no egress fees and is
102+
the primary distribution target -- but a build can still explicitly opt out
103+
(e.g. an S3-only test).
104+
"""
105+
return self.deploy_to_gcs if self.deploy_to_gcs is not None else True
106+
107+
@property
108+
def upload_to_s3(self) -> bool:
109+
"""Whether this deployment uploads outputs to S3.
110+
111+
Nightly and stable deploys default to ``True``. Branch builds default to
112+
``False``: S3 egress costs more than a full ETL run, and the nightly build
113+
exercises the real S3 deployment every night anyway. A branch build can
114+
still opt in explicitly when that's the thing being tested.
115+
"""
116+
if self.deploy_to_s3 is not None:
117+
return self.deploy_to_s3
118+
return self.deploy_type != DeploymentType.BRANCH
119+
82120
@property
83121
def path_suffixes(self) -> list[str]:
84122
"""Cloud storage path suffixes this deployment uploads to.
@@ -317,8 +355,8 @@ def _upload_to_path(fs, path: str, source_dir: Path, clear_first: bool) -> None:
317355

318356

319357
def _assert_permanent_paths_are_empty(
320-
gcs_fs: gcsfs.GCSFileSystem,
321-
s3_fs: s3fs.S3FileSystem,
358+
gcs_fs: gcsfs.GCSFileSystem | None,
359+
s3_fs: s3fs.S3FileSystem | None,
322360
path_suffixes: list[str],
323361
immutable_suffixes: frozenset[str],
324362
) -> None:
@@ -339,6 +377,8 @@ def _assert_permanent_paths_are_empty(
339377
(gcs_fs, f"gs://pudl.catalyst.coop/{suffix}/"),
340378
(s3_fs, f"s3://pudl.catalyst.coop/{suffix}/"),
341379
):
380+
if fs is None:
381+
continue
342382
if fs.exists(path):
343383
raise RuntimeError(
344384
f"Refusing to deploy to {path}: it's a permanent, "
@@ -352,15 +392,18 @@ def upload_outputs(
352392
source_dir: Path,
353393
path_suffixes: list[str],
354394
immutable_suffixes: frozenset[str] = frozenset(),
395+
upload_to_gcs: bool = True,
396+
upload_to_s3: bool = True,
355397
) -> None:
356398
"""Upload outputs to cloud storage paths.
357399
358-
Uploads all files from source directory to GCS and S3 using the provided path
359-
suffixes. Each suffix is uploaded to both gs://pudl.catalyst.coop/{suffix}/ and
360-
s3://pudl.catalyst.coop/{suffix}/. Any existing objects at a suffix are removed
361-
first, unless that suffix is listed in ``immutable_suffixes`` -- a permanent,
362-
hold-protected versioned release path is never cleared, and instead must not
363-
exist at all yet (see ``_assert_permanent_paths_are_empty``).
400+
Uploads all files from source directory to GCS and/or S3 using the provided path
401+
suffixes. Each enabled destination gets every suffix uploaded to
402+
gs://pudl.catalyst.coop/{suffix}/ and/or s3://pudl.catalyst.coop/{suffix}/. Any
403+
existing objects at a suffix are removed first, unless that suffix is listed in
404+
``immutable_suffixes`` -- a permanent, hold-protected versioned release path is
405+
never cleared, and instead must not exist at all yet (see
406+
``_assert_permanent_paths_are_empty``).
364407
365408
Each (suffix, destination) pair is uploaded concurrently: GCS and S3 are separate
366409
network destinations, and this is I/O-bound work that releases the GIL.
@@ -371,32 +414,41 @@ def upload_outputs(
371414
immutable_suffixes: Path suffixes that should never be cleared before upload
372415
(e.g. a permanent stable-version path like "v2026.7.0"). It's an error
373416
for one of these paths to already exist.
417+
upload_to_gcs: Whether to upload to GCS.
418+
upload_to_s3: Whether to upload to S3. Branch builds skip S3 by default
419+
because its egress fees are large and the nightly build tests it anyway.
374420
375421
Raises:
422+
ValueError: If neither ``upload_to_gcs`` nor ``upload_to_s3`` is enabled.
376423
RuntimeError: If a permanent, immutable path already has content.
377424
"""
378425
logger.info("Uploading outputs to cloud storage")
379426

427+
if not upload_to_gcs and not upload_to_s3:
428+
raise ValueError(
429+
"upload_outputs called with neither GCS nor S3 uploads enabled."
430+
)
380431
if not source_dir.exists():
381432
raise ValueError(f"Source directory does not exist: {source_dir}")
382433
if not any(source_dir.iterdir()):
383434
raise ValueError(f"Source directory is empty: {source_dir}")
384435

385436
# NOTE (2026-02-11): our GCS distribution bucket is requester pays.
386-
gcs_fs = gcsfs.GCSFileSystem(requester_pays=True)
387-
s3_fs = s3fs.S3FileSystem()
437+
gcs_fs = gcsfs.GCSFileSystem(requester_pays=True) if upload_to_gcs else None
438+
s3_fs = s3fs.S3FileSystem() if upload_to_s3 else None
388439

389440
_assert_permanent_paths_are_empty(gcs_fs, s3_fs, path_suffixes, immutable_suffixes)
390441

442+
destinations = [
443+
(fs, scheme) for fs, scheme in ((gcs_fs, "gs"), (s3_fs, "s3")) if fs is not None
444+
]
391445
upload_targets = []
392446
for suffix in path_suffixes:
393447
clear_first = suffix not in immutable_suffixes
394-
upload_targets.append(
395-
(gcs_fs, f"gs://pudl.catalyst.coop/{suffix}/", clear_first)
396-
)
397-
upload_targets.append(
398-
(s3_fs, f"s3://pudl.catalyst.coop/{suffix}/", clear_first)
399-
)
448+
for fs, scheme in destinations:
449+
upload_targets.append(
450+
(fs, f"{scheme}://pudl.catalyst.coop/{suffix}/", clear_first)
451+
)
400452

401453
with ThreadPoolExecutor(max_workers=len(upload_targets)) as executor:
402454
futures = [
@@ -704,15 +756,23 @@ class ResolvedBuild:
704756

705757

706758
def resolve_build(
707-
git_tag: str, environment: Literal["staging", "production"]
759+
git_tag: str,
760+
environment: Literal["staging", "production"],
761+
deploy_to_gcs: bool | None = None,
762+
deploy_to_s3: bool | None = None,
708763
) -> ResolvedBuild:
709764
"""Resolve the deployment plan, locate the build, and set up local logging.
710765
711766
Raises if ``git_tag`` doesn't look like a nightly/stable/branch tag, if a
712-
branch tag is being deployed to production, or if no successful build exists
713-
for the tag yet.
767+
branch tag is being deployed to production, if no successful build exists
768+
for the tag yet, or if both cloud storage upload targets are disabled.
714769
"""
715-
plan = DeploymentPlan(git_tag=git_tag, environment=environment)
770+
plan = DeploymentPlan(
771+
git_tag=git_tag,
772+
environment=environment,
773+
deploy_to_gcs=deploy_to_gcs,
774+
deploy_to_s3=deploy_to_s3,
775+
)
716776

717777
build_path = get_build_from_tag(git_tag)
718778
build_id = build_path.name

src/pudl/scripts/pudl_deploy.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ def _deploy_outputs(
8484
source_dir=source_dir,
8585
path_suffixes=plan.path_suffixes,
8686
immutable_suffixes=plan.immutable_suffixes,
87+
upload_to_gcs=plan.upload_to_gcs,
88+
upload_to_s3=plan.upload_to_s3,
8789
)
8890

8991
if plan.redeploy_eel_hole:
@@ -150,11 +152,30 @@ def _deploy_outputs(
150152
),
151153
show_default=True,
152154
)
155+
@click.option(
156+
"--deploy-gcs/--no-deploy-gcs",
157+
"deploy_to_gcs",
158+
default=None,
159+
help=(
160+
"Force GCS upload on or off. If unset, defaults to on for every deploy type."
161+
),
162+
)
163+
@click.option(
164+
"--deploy-s3/--no-deploy-s3",
165+
"deploy_to_s3",
166+
default=None,
167+
help=(
168+
"Force S3 upload on or off. If unset, defaults to on for nightly/stable "
169+
"deploys and off for branch builds (S3 egress fees are large)."
170+
),
171+
)
153172
@click.pass_context
154173
def main(
155174
ctx: click.Context,
156175
git_tag: str,
157176
environment: Literal["staging", "production"],
177+
deploy_to_gcs: bool | None,
178+
deploy_to_s3: bool | None,
158179
) -> None:
159180
"""Deploy PUDL ETL outputs to cloud storage and external services.
160181
@@ -197,6 +218,8 @@ def main(
197218
stage_results=stage_results,
198219
git_tag=git_tag,
199220
environment=environment,
221+
deploy_to_gcs=deploy_to_gcs,
222+
deploy_to_s3=deploy_to_s3,
200223
)
201224
# run_stage's default fail_hard=True re-raises on failure instead of
202225
# returning, so reaching this line means resolve_build succeeded.

0 commit comments

Comments
 (0)