Repository: dagster-io/dagster
Pull request #29065: [dg] Add `dg scaffold github-action` command support for hybrid orgs
Author: login: benpankow
Created: 2025-04-07T16:45:43Z | Merged: 2025-04-17T16:36:46Z

## Summary

Stacks on #28962. Implements `dg scaffold github-action` for hybrid orgs.

`dg scaffold github-action` queries Plus to figure out which agent type is in use, and scaffolds the corresponding GitHub actions file. It looks for a `regsitry.yaml` file to determine which registry type to log in to, and scaffolds a build step and dockerfile (if one doesn't yet exist) for each location.


## Test Plan

Mocked unit tests.



Diff:
diff --git a/python_modules/libraries/dagster-dg/dagster_dg/cli/plus/deploy_session.py b/python_modules/libraries/dagster-dg/dagster_dg/cli/plus/deploy_session.py
index 55657dc770b30..386a8d3c0162c 100644
--- a/python_modules/libraries/dagster-dg/dagster_dg/cli/plus/deploy_session.py
+++ b/python_modules/libraries/dagster-dg/dagster_dg/cli/plus/deploy_session.py
@@ -8,13 +8,13 @@
 
 import click
 import dagster_shared.check as check
-import jinja2
 
 from dagster_dg.cli.plus.constants import DgPlusAgentType, DgPlusDeploymentType
 from dagster_dg.cli.utils import create_temp_dagster_cloud_yaml_file
 from dagster_dg.config import DgRawBuildConfig, merge_build_configs
 from dagster_dg.context import DgContext
 from dagster_dg.utils.git import get_local_branch_name
+from dagster_dg.utils.plus.build import create_deploy_dockerfile
 
 
 def _guess_deployment_type(
@@ -47,27 +47,6 @@ def _guess_and_prompt_deployment_type(
     return deployment_type
 
 
-def _create_temp_deploy_dockerfile(dst_path, python_version, use_editable_dagster: bool):
-    dockerfile_template_path = (
-        Path(__file__).parent.parent.parent
-        / "templates"
-        / (
-            "deploy_uv_editable_Dockerfile.jinja"
-            if use_editable_dagster
-            else "deploy_uv_Dockerfile.jinja"
-        )
-    )
-
-    loader = jinja2.FileSystemLoader(searchpath=os.path.dirname(dockerfile_template_path))
-    env = jinja2.Environment(loader=loader)
-
-    template = env.get_template(os.path.basename(dockerfile_template_path))
-
-    with open(dst_path, "w", encoding="utf8") as f:
-        f.write(template.render(python_version=python_version))
-        f.write("\n")
-
-
 def _build_hybrid_image(
     dg_context: DgContext,
     dockerfile_path: Path,
@@ -243,7 +222,7 @@ def _build_artifact_for_project(
     dockerfile_path = build_directory / "Dockerfile"
     if not os.path.exists(dockerfile_path):
         click.echo(f"No Dockerfile found - scaffolding a default one at {dockerfile_path}.")
-        _create_temp_deploy_dockerfile(dockerfile_path, python_version, use_editable_dagster)
+        create_deploy_dockerfile(dockerfile_path, python_version, use_editable_dagster)
     else:
         click.echo(f"Building using Dockerfile at {dockerfile_path}.")
 
diff --git a/python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py b/python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
index 045d46cce7ce6..d487be1b27c96 100644
--- a/python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
+++ b/python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
@@ -1,8 +1,10 @@
+import itertools
 import subprocess
+import textwrap
 from collections.abc import Mapping
 from copy import copy
 from pathlib import Path
-from typing import Any, Optional, cast, get_args
+from typing import Any, Callable, NamedTuple, Optional, cast, get_args
 
 import click
 import typer
@@ -27,6 +29,7 @@
     DgWorkspaceScaffoldProjectOptions,
     get_config_from_cli_context,
     has_config_on_cli_context,
+    merge_build_configs,
     normalize_cli_config,
     set_config_on_cli_context,
 )
@@ -48,6 +51,7 @@
     parse_json_option,
     snakecase,
 )
+from dagster_dg.utils.plus.build import create_deploy_dockerfile, get_agent_type
 from dagster_dg.utils.telemetry import cli_telemetry_wrapper
 
 DEFAULT_WORKSPACE_NAME = "dagster-workspace"
@@ -208,19 +212,99 @@ def _search_for_git_root(path: Path) -> Optional[Path]:
 SERVERLESS_GITHUB_ACTION_FILE = (
     Path(__file__).parent.parent / "templates" / "serverless-github-action.yaml"
 )
+HYBRID_GITHUB_ACTION_FILE = Path(__file__).parent.parent / "templates" / "hybrid-github-action.yaml"
 
 
-def _generate_dagster_cloud_yaml_contents(
-    dg_context: DgContext, git_root: Path, cli_config: DgRawCliConfig
-) -> dict:
-    project_contexts = (
-        [
+BUILD_LOCATION_FRAGMENT = (
+    Path(__file__).parent.parent / "templates" / "build-location-fragment.yaml"
+)
+
+
+class ContainerRegistryInfo(NamedTuple):
+    name: str
+    match: Callable[[str], bool]
+    fragment: Path
+    secrets_hints: list[str]
+
+
+REGISTRY_INFOS = [
+    ContainerRegistryInfo(
+        name="ECR",
+        match=lambda url: "ecr" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "ecr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AWS_ACCESS_KEY_ID --body "(your AWS access key ID)"',
+            'gh secret set AWS_SECRET_ACCESS_KEY --body "(your AWS secret access key)"',
+            'gh secret set AWS_REGION --body "(your AWS region)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="DockerHub",
+        match=lambda url: "docker.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "dockerhub-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set DOCKERHUB_USERNAME --body "(your DockerHub username)"',
+            'gh secret set DOCKERHUB_TOKEN --body "(your DockerHub token)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="GitHub Container Registry",
+        match=lambda url: "ghcr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "github-container-registry-login-fragment.yaml",
+        secrets_hints=[],
+    ),
+    ContainerRegistryInfo(
+        name="Azure Container Registry",
+        match=lambda url: "azurecr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "azure-container-registry-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AZURE_CLIENT_ID --body "(your Azure client ID)"',
+            'gh secret set AZURE_CLIENT_SECRET --body "(your Azure client secret)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="Google Container Registry",
+        match=lambda url: "gcr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "gcr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set GCR_JSON_KEY --body "(your GCR JSON key)"',
+        ],
+    ),
+]
+
+
+def _get_project_contexts(dg_context: DgContext, cli_config: DgRawCliConfig) -> list[DgContext]:
+    if dg_context.is_workspace:
+        return [
             dg_context.for_project_environment(project.path, cli_config)
             for project in dg_context.project_specs
         ]
-        if dg_context.is_workspace
-        else [dg_context]
-    )
+    else:
+        return [dg_context]
+
+
+def _generate_dagster_cloud_yaml_contents(
+    dg_context: DgContext,
+    git_root: Path,
+    cli_config: DgRawCliConfig,
+    registry_urls: Optional[list[str]],
+) -> dict:
+    project_contexts = _get_project_contexts(dg_context, cli_config)
     return {
         "locations": [
             {
@@ -228,9 +312,12 @@ def _generate_dagster_cloud_yaml_contents(
                 "code_source": {"module_name": project_context.code_location_target_module_name},
                 "build": {
                     "directory": str(project_context.root_path.relative_to(git_root)),
+                    **({"registry": registry_url} if registry_url else {}),
                 },
             }
-            for project_context in project_contexts
+            for project_context, registry_url in zip(
+                project_contexts, registry_urls or itertools.repeat(None)
+            )
         ]
     }
 
@@ -248,6 +335,22 @@ def _get_git_web_url(git_root: Path) -> Optional[str]:
         return None
 
 
+def _get_build_fragment_for_locations(
+    location_ctxs: list[DgContext], git_root: Path, registry_urls: list[str]
+) -> str:
+    # TODO: when we cut over to dg deploy, we'll just use a single build call for the workspace
+    # rather than iterating over each project
+    output = []
+    for location_ctx, registry_url in zip(location_ctxs, registry_urls):
+        output.append(
+            textwrap.indent(BUILD_LOCATION_FRAGMENT.read_text(), " " * 6)
+            .replace("LOCATION_NAME", location_ctx.code_location_name)
+            .replace("LOCATION_PATH", str(location_ctx.root_path.relative_to(git_root)))
+            .replace("IMAGE_REGISTRY_TEMPLATE", registry_url)
+        )
+    return "\n" + "\n".join(output)
+
+
 @scaffold_group.command(
     name="github-actions",
     cls=ScaffoldSubCommand,
@@ -277,15 +380,69 @@ def scaffold_github_actions_command(git_root: Optional[Path], **global_options:
     workflows_dir = git_root / ".github" / "workflows"
     workflows_dir.mkdir(parents=True, exist_ok=True)
 
-    template = SERVERLESS_GITHUB_ACTION_FILE.read_text()
-
     if plus_config and plus_config.organization:
         organization_name = plus_config.organization
         click.echo(f"Using organization name {organization_name} from Dagster Plus config.")
     else:
         organization_name = click.prompt("Dagster Plus organization name") or ""
 
-    template = template.replace("ORGANIZATION_NAME", organization_name)
+    if plus_config and plus_config.default_deployment:
+        deployment_name = plus_config.default_deployment
+        click.echo(f"Using default deployment name {deployment_name} from Dagster Plus config.")
+    else:
+        deployment_name = click.prompt("Default deployment name", default="prod")
+
+    agent_type = get_agent_type(plus_config)
+    if agent_type == "SERVERLESS":
+        click.echo("Using serverless workflow template.")
+    else:
+        click.echo("Using hybrid workflow template.")
+
+    additional_secrets_hints = []
+
+    template = (
+        SERVERLESS_GITHUB_ACTION_FILE.read_text()
+        if agent_type == "SERVERLESS"
+        else HYBRID_GITHUB_ACTION_FILE.read_text()
+    )
+
+    template = template.replace("ORGANIZATION_NAME", organization_name).replace(
+        "DEFAULT_DEPLOYMENT_NAME", deployment_name
+    )
+
+    registry_urls = None
+    if agent_type == "HYBRID":
+        project_contexts = _get_project_contexts(dg_context, cli_config)
+
+        registry_urls = [
+            merge_build_configs(project.build_config, dg_context.build_config).get("registry")
+            for project in project_contexts
+        ]
+        for project, registry_url in zip(project_contexts, registry_urls):
+            if registry_url is None:
+                raise click.ClickException(
+                    f"No registry URL found for project {project.code_location_name}. Please specify a registry URL in `build.yaml`."
+                )
+        registry_urls = cast("list[str]", registry_urls)
+
+        for location_ctx in project_contexts:
+            create_deploy_dockerfile(
+                location_ctx.root_path / "Dockerfile",
+                "3.11",
+                use_editable_dagster=False,
+            )
+        build_fragment = _get_build_fragment_for_locations(
+            project_contexts, git_root, registry_urls
+        )
+        template = template.replace("# BUILD_LOCATION_FRAGMENT", build_fragment)
+
+        for registry_info in REGISTRY_INFOS:
+            if any(registry_info.match(registry_url) for registry_url in registry_urls):
+                template = template.replace(
+                    "# CONTAINER_REGISTRY_LOGIN_FRAGMENT",
+                    textwrap.indent(registry_info.fragment.read_text(), " " * 6),
+                )
+                additional_secrets_hints.extend(registry_info.secrets_hints)
 
     workflow_file = workflows_dir / "dagster-plus-deploy.yml"
     workflow_file.write_text(template)
@@ -293,7 +450,7 @@ def scaffold_github_actions_command(git_root: Optional[Path], **global_options:
     dagster_cloud_yaml_file = git_root / "dagster_cloud.yaml"
 
     dagster_cloud_yaml_contents = _generate_dagster_cloud_yaml_contents(
-        dg_context, git_root, cli_config
+        dg_context, git_root, cli_config, registry_urls
     )
     dagster_cloud_yaml_file.write_text(
         f"# Generated by `dg scaffold github-actions`\n{yaml.dump(dagster_cloud_yaml_contents)}"
@@ -309,7 +466,9 @@ def scaffold_github_actions_command(git_root: Optional[Path], **global_options:
         )
         + f"\nYou will need to set up the following secrets in your GitHub repository using\nthe GitHub UI {git_web_url}or CLI (https://cli.github.com/):"
         + typer.style(
-            f"\ndg plus create ci-api-token --description 'Used in {git_root.name} GitHub Actions' | gh secret set DAGSTER_CLOUD_API_TOKEN",
+            f"\ndg plus create ci-api-token --description 'Used in {git_root.name} GitHub Actions' | gh secret set DAGSTER_CLOUD_API_TOKEN"
+            + "\n"
+            + "\n".join(additional_secrets_hints),
             fg=typer.colors.BLUE,
         )
     )
diff --git a/python_modules/libraries/dagster-dg/dagster_dg/templates/build-location-fragment.yaml b/python_modules/libraries/dagster-dg/dagster_dg/templates/build-location-fragment.yaml
new file mode 100644
index 0000000000000..d96c52fb51577
--- /dev/null
+++ b/python_modules/libraries/dagster-dg/dagster_dg/templates/build-location-fragment.yaml
@@ -0,0 +1,16 @@
+- name: Build and upload Docker image for "LOCATION_NAME"
+  if: steps.prerun.outputs.result != 'skip'
+  uses: docker/build-push-action@v4
+  with:
+    context: LOCATION_PATH
+    push: true
+    tags: ${{ env.IMAGE_REGISTRY }}:${{ env.IMAGE_TAG }}-LOCATION_NAME
+    cache-from: type=gha
+    cache-to: type=gha,mode=max
+
+- name: Update build session with image tag for LOCATION_NAME
+  id: ci-set-build-output-example-location
+  if: steps.prerun.outputs.result != 'skip'
+  uses: dagster-io/dagster-cloud-action/actions/utils/dagster-cloud-cli@v0.1
+  with:
+    command: "ci set-build-output --location-name=LOCATION_NAME --image-tag=$IMAGE_TAG-LOCATION_NAME"
diff --git a/python_modules/libraries/dagster-dg/dagster_dg/templates/hybrid-github-action.yaml b/python_modules/libraries/dagster-dg/dagster_dg/templates/hybrid-github-action.yaml
new file mode 100644
index 0000000000000..3b8e63aa33b05
--- /dev/null
+++ b/python_modules/libraries/dagster-dg/dagster_dg/templates/hybrid-github-action.yaml
@@ -0,0 +1,105 @@
+name: Dagster Cloud Hybrid Deployment
+on:
+  push: # For full deployments
+    branches:
+      - "main"
+      - "master"
+  pull_request: # For branch deployments
+    types: [opened, synchronize, reopened, closed]
+
+concurrency:
+  # Cancel in-progress deploys to the same branch
+  group: ${{ github.ref }}-${{ github.event_name }}
+  cancel-in-progress: true
+env:
+  # The organization name in Dagster Cloud
+  DAGSTER_CLOUD_ORGANIZATION: "ORGANIZATION_NAME"
+  # The API token from https://dagster.cloud/ should be stored in Secrets
+  DAGSTER_CLOUD_API_TOKEN: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+  # Path to the root folder containing the dagster project
+  DAGSTER_PROJECT_DIR: "."
+  # Path to dagster_cloud.yaml relative to DAGSTER_PROJECT_DIR
+  DAGSTER_CLOUD_YAML_PATH: "dagster_cloud.yaml"
+  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+jobs:
+  dagster-cloud-deploy:
+    runs-on: ubuntu-22.04
+    steps:
+      # If this is a closed PR the prerun step closes the branch deployment and returns
+      # output.result='skip' which is used to skip other steps in this workflow.
+      - name: Pre-run checks
+        id: prerun
+        uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v0.1
+
+      # Checkout the project
+      - name: Checkout
+        uses: actions/checkout@v3
+        if: steps.prerun.outputs.result != 'skip'
+        with:
+          ref: ${{ github.head_ref }}
+
+      # Validate dagster_cloud.yaml and the connection to dagster.cloud
+      - name: Validate configuration
+        id: ci-validate
+        if: steps.prerun.outputs.result != 'skip'
+        uses: dagster-io/dagster-cloud-action/actions/utils/dagster-cloud-cli@v0.1
+        with:
+          command: "ci check --project-dir ${{ env.DAGSTER_PROJECT_DIR }} --dagster-cloud-yaml-path ${{ env.DAGSTER_CLOUD_YAML_PATH }}"
+
+      # Parse dagster_cloud.yaml, detect if this is branch deployment and initialize the build session
+      - name: Initialize build session
+        id: ci-init
+        if: steps.prerun.outputs.result != 'skip'
+        uses: dagster-io/dagster-cloud-action/actions/utils/ci-init@v0.1
+        with:
+          project_dir: ${{ env.DAGSTER_PROJECT_DIR }}
+          dagster_cloud_yaml_path: ${{ env.DAGSTER_CLOUD_YAML_PATH }}
+          # A full deployment name. If this run is for a pull request, this value will be used as
+          # the base deployment for the branch deployment.
+          deployment: "DEFAULT_DEPLOYMENT_NAME"
+
+      # Any value can be used as the docker image tag. It is recommended to use a unique value
+      # for each build so that multiple builds do not overwrite each other.
+      - name: Generate docker image tag
+        id: generate-image-tag
+        if: steps.prerun.outputs.result != 'skip'
+        run: echo "IMAGE_TAG=$GITHUB_SHA-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" >> $GITHUB_ENV && echo $IMAGE_TAG
+
+      # Enable buildx for caching
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v2
+
+      # Login to the container registry
+      # CONTAINER_REGISTRY_LOGIN_FRAGMENT
+
+      # For each code location, the "build-push-action" builds the docker
+      # image and a "set-build-output" command records the image tag for each code location.
+      # To re-use the same docker image across multiple code locations, build the docker image once
+      # and specify the same tag in multiple "set-build-output" commands. To use a different docker
+      # image for each code location, use multiple "build-push-actions" with a location specific
+      # tag.
+      # BUILD_LOCATION_FRAGMENT
+
+      # Deploy all code locations in this build session to Dagster Cloud
+      - name: Deploy to Dagster Cloud
+        id: ci-deploy
+        if: steps.prerun.outputs.result != 'skip'
+        uses: dagster-io/dagster-cloud-action/actions/utils/dagster-cloud-cli@v0.1
+        with:
+          command: "ci deploy"
+
+      # Update a PR comment - this runs always() so the comment is updated on success and failure
+      - name: Update PR comment for branch deployments
+        id: ci-notify
+        if: steps.prerun.outputs.result != 'skip' && always()
+        uses: dagster-io/dagster-cloud-action/actions/utils/dagster-cloud-cli@v0.1
+        with:
+          command: "ci notify --project-dir=${{ env.DAGSTER_PROJECT_DIR }}"
+
+      # Generate a summary that shows up on the Workflow Summary page
+      - name: Generate a summary
+        id: ci-summary
+        if: steps.prerun.outputs.result != 'skip' && always()
+        uses: dagster-io/dagster-cloud-action/actions/utils/dagster-cloud-cli@v0.1
+        with:
+          command: "ci status --output-format=markdown >> $GITHUB_STEP_SUMMARY"
diff --git a/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/azure-container-registry-login-fragment.yaml b/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/azure-container-registry-login-fragment.yaml
new file mode 100644
index 0000000000000..d230ad09a67b2
--- /dev/null
+++ b/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/azure-container-registry-login-fragment.yaml
@@ -0,0 +1,9 @@
+# Azure Container Registry (ACR)
+# https://github.com/docker/login-action#azure-container-registry-acr
+- name: Login to Azure Container Registry
+  if: steps.prerun.outputs.result != 'skip'
+  uses: docker/login-action@v3
+  with:
+    registry: "IMAGE_REGISTRY_TEMPLATE"
+    username: ${{ secrets.AZURE_CLIENT_ID }}
+    password: ${{ secrets.AZURE_CLIENT_SECRET }}
diff --git a/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/dockerhub-login-fragment.yaml b/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/dockerhub-login-fragment.yaml
new file mode 100644
index 0000000000000..395664e002182
--- /dev/null
+++ b/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/dockerhub-login-fragment.yaml
@@ -0,0 +1,8 @@
+# DockerHub
+# https://github.com/docker/login-action#docker-hub
+- name: Login to Docker Hub
+  if: steps.prerun.outputs.result != 'skip'
+  uses: docker/login-action@v1
+  with:
+    username: ${{ secrets.DOCKERHUB_USERNAME }}
+    password: ${{ secrets.DOCKERHUB_TOKEN }}
diff --git a/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/ecr-login-fragment.yaml b/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/ecr-login-fragment.yaml
new file mode 100644
index 0000000000000..20565a0d77949
--- /dev/null
+++ b/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/ecr-login-fragment.yaml
@@ -0,0 +1,12 @@
+# AWS ECR
+# https://github.com/aws-actions/amazon-ecr-login
+- name: Configure AWS credentials
+  if: steps.prerun.outputs.result != 'skip'
+  uses: aws-actions/configure-aws-credentials@v2
+  with:
+    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+    aws-region: ${{ secrets.AWS_REGION }}
+- name: Login to ECR
+  if: steps.prerun.outputs.result != 'skip'
+  uses: aws-actions/amazon-ecr-login@v1
diff --git a/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/gcr-login-fragment.yaml b/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/gcr-login-fragment.yaml
new file mode 100644
index 0000000000000..53a916dfab496
--- /dev/null
+++ b/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/gcr-login-fragment.yaml
@@ -0,0 +1,9 @@
+# GCR
+# https://github.com/docker/login-action#google-container-registry-gcr
+- name: Login to GCR
+  if: steps.prerun.outputs.result != 'skip'
+  uses: docker/login-action@v1
+  with:
+    registry: gcr.io
+    username: _json_key
+    password: ${{ secrets.GCR_JSON_KEY }}
diff --git a/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/github-container-registry-login-fragment.yaml b/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/github-container-registry-login-fragment.yaml
new file mode 100644
index 0000000000000..d703b2d367682
--- /dev/null
+++ b/python_modules/libraries/dagster-dg/dagster_dg/templates/registry_fragments/github-container-registry-login-fragment.yaml
@@ -0,0 +1,9 @@
+# GitHub Container Registry
+# https://github.com/docker/login-action#github-container-registry
+- name: Login to GitHub Container Registry
+  if: steps.prerun.outputs.result != 'skip'
+  uses: docker/login-action@v1
+  with:
+    registry: ghcr.io
+    username: ${{ github.actor }}
+    password: ${{ secrets.GITHUB_TOKEN }}
diff --git a/python_modules/libraries/dagster-dg/dagster_dg/templates/serverless-github-action.yaml b/python_modules/libraries/dagster-dg/dagster_dg/templates/serverless-github-action.yaml
index 1fbd99891ae78..60301a2207bec 100644
--- a/python_modules/libraries/dagster-dg/dagster_dg/templates/serverless-github-action.yaml
+++ b/python_modules/libraries/dagster-dg/dagster_dg/templates/serverless-github-action.yaml
@@ -52,7 +52,7 @@ jobs:
           dagster_cloud_yaml_path: ${{ env.DAGSTER_CLOUD_FILE }}
           # A full deployment name. If this run is for a pull request, this value will be used as
           # the base deployment for the branch deployment.
-          deployment: "prod"
+          deployment: "DEFAULT_DEPLOYMENT_NAME"
 
       # If using fast build, build the PEX
       # First ensure the correct Python version is installed
diff --git a/python_modules/libraries/dagster-dg/dagster_dg/utils/plus/build.py b/python_modules/libraries/dagster-dg/dagster_dg/utils/plus/build.py
new file mode 100644
index 0000000000000..1733aa44a180a
--- /dev/null
+++ b/python_modules/libraries/dagster-dg/dagster_dg/utils/plus/build.py
@@ -0,0 +1,53 @@
+import os
+from enum import Enum
+from pathlib import Path
+from typing import Optional
+
+import click
+import jinja2
+from dagster_shared.plus.config import DagsterPlusCliConfig
+
+from dagster_dg.utils.plus.gql import DEPLOYMENT_INFO_QUERY
+from dagster_dg.utils.plus.gql_client import DagsterPlusGraphQLClient
+
+
+class AgentType(str, Enum):
+    SERVERLESS = "SERVERLESS"
+    HYBRID = "HYBRID"
+
+
+def get_agent_type(cli_config: Optional[DagsterPlusCliConfig] = None) -> AgentType:
+    if cli_config:
+        gql_client = DagsterPlusGraphQLClient.from_config(cli_config)
+        result = gql_client.execute(DEPLOYMENT_INFO_QUERY)
+        return AgentType(result["currentDeployment"]["agentType"])
+    else:
+        return AgentType(
+            click.prompt(
+                "Deployment agent type: ",
+                type=click.Choice(
+                    [agent_type.lower() for agent_type in AgentType.__members__.keys()]
+                ),
+            ).upper()
+        )
+
+
+def create_deploy_dockerfile(dst_path, python_version, use_editable_dagster: bool):
+    dockerfile_template_path = (
+        Path(__file__).parent.parent.parent
+        / "templates"
+        / (
+            "deploy_uv_editable_Dockerfile.jinja"
+            if use_editable_dagster
+            else "deploy_uv_Dockerfile.jinja"
+        )
+    )
+
+    loader = jinja2.FileSystemLoader(searchpath=os.path.dirname(dockerfile_template_path))
+    env = jinja2.Environment(loader=loader)
+
+    template = env.get_template(os.path.basename(dockerfile_template_path))
+
+    with open(dst_path, "w", encoding="utf8") as f:
+        f.write(template.render(python_version=python_version))
+        f.write("\n")
diff --git a/python_modules/libraries/dagster-dg/dagster_dg/utils/plus/gql.py b/python_modules/libraries/dagster-dg/dagster_dg/utils/plus/gql.py
index 9a9697c15c34f..f0d2fbeb1509d 100644
--- a/python_modules/libraries/dagster-dg/dagster_dg/utils/plus/gql.py
+++ b/python_modules/libraries/dagster-dg/dagster_dg/utils/plus/gql.py
@@ -32,34 +32,6 @@
 """
 
 
-SECRETS_QUERY = """
-{
-	secretsOrError {
-    __typename
-    ... on Secrets {
-      secrets {
-        id
-        localDeploymentScope
-        fullDeploymentScope
-        allBranchDeploymentsScope
-        specificBranchDeploymentScope
-        secretName
-        secretValue
-        locationNames
-      }
-    }
-    ... on UnauthorizedError {
-        message
-    }
-    ... on PythonError {
-        message
-        stack
-    }
-  }
-}
-"""
-
-
 CREATE_OR_UPDATE_SECRET_FOR_SCOPES_MUTATION = """
     mutation CreateOrUpdateSecretForScopes($secretName: String!, $secretValue: String!, $scopes: SecretScopesInput!, $locationName: String) {
         createOrUpdateSecretForScopes(secretName: $secretName, secretValue: $secretValue, scopes: $scopes, locationName: $locationName) {
@@ -97,15 +69,11 @@
                 id
                 secretName
                 secretValue
-
                 updatedBy {
                     email
                 }
-
                 updateTimestamp
-
                 locationNames
-
                 fullDeploymentScope
                 allBranchDeploymentsScope
                 specificBranchDeploymentScope
@@ -125,6 +93,14 @@
 }
 """
 
+DEPLOYMENT_INFO_QUERY = """
+query DeploymentInfoQuery {
+	currentDeployment {
+        agentType
+    }
+}
+"""
+
 AGENT_TOKENS_QUERY = """
 query AgentTokensQuery {
     agentTokensOrError {
diff --git a/python_modules/libraries/dagster-dg/dagster_dg_tests/cli_tests/plus_tests/test_scaffold_github_actions_command.py b/python_modules/libraries/dagster-dg/dagster_dg_tests/cli_tests/plus_tests/test_scaffold_github_actions_command.py
index b64df4ad5c568..1ee2a358f0af1 100644
--- a/python_modules/libraries/dagster-dg/dagster_dg_tests/cli_tests/plus_tests/test_scaffold_github_actions_command.py
+++ b/python_modules/libraries/dagster-dg/dagster_dg_tests/cli_tests/plus_tests/test_scaffold_github_actions_command.py
@@ -1,14 +1,18 @@
 import subprocess
 import tempfile
 from pathlib import Path
-from unittest import mock
 
 import pytest
 import yaml
+from dagster_dg.cli.scaffold import REGISTRY_INFOS
 from dagster_dg.utils import ensure_dagster_dg_tests_import
+from dagster_dg.utils.plus import gql
 
 ensure_dagster_dg_tests_import()
 
+import responses
+
+from dagster_dg_tests.cli_tests.plus_tests.utils import mock_gql_response
 from dagster_dg_tests.utils import (
     ProxyRunner,
     isolated_example_project_foo_bar,
@@ -16,26 +20,6 @@
 )
 
 
-@pytest.fixture
-def mock_has_github_cli():
-    with mock.patch("dagster_dg.cli.scaffold._has_github_cli") as mock_has_github_cli:
-        yield mock_has_github_cli
-
-
-@pytest.fixture
-def mock_logged_in_to_github():
-    with mock.patch("dagster_dg.cli.scaffold._logged_in_to_github") as mock_logged_in_to_github:
-        yield mock_logged_in_to_github
-
-
-@pytest.fixture
-def mock_get_or_create_agent_token():
-    with mock.patch(
-        "dagster_dg.cli.scaffold._get_or_create_agent_token"
-    ) as mock_get_or_create_agent_token:
-        yield mock_get_or_create_agent_token
-
-
 @pytest.fixture
 def setup_populated_git_workspace():
     with (
@@ -44,7 +28,8 @@ def setup_populated_git_workspace():
     ):
         subprocess.run(["git", "init"], check=False)
         subprocess.run(
-            ["git", "remote", "add", "origin", "git@github.com:hooli/example-repo.git"], check=False
+            ["git", "remote", "add", "origin", "git@github.com:hooli/example-repo.git"],
+            check=False,
         )
         runner.invoke("scaffold", "project", "foo")
         runner.invoke("scaffold", "project", "bar")
@@ -73,10 +58,15 @@ def setup_populated_git_workspace():
 }
 
 
-def test_scaffold_github_actions_command_success(
+@responses.activate
+def test_scaffold_github_actions_command_success_serverless(
     dg_plus_cli_config,
-    setup_populated_git_workspace,
+    setup_populated_git_workspace: ProxyRunner,
 ):
+    mock_gql_response(
+        query=gql.DEPLOYMENT_INFO_QUERY,
+        json_data={"data": {"currentDeployment": {"agentType": "SERVERLESS"}}},
+    )
     runner = setup_populated_git_workspace
     result = runner.invoke("scaffold", "github-actions")
     assert result.exit_code == 0, result.output + " " + str(result.exception)
@@ -88,9 +78,14 @@ def test_scaffold_github_actions_command_success(
     assert "https://github.com/hooli/example-repo/settings/secrets/actions" in result.output
 
 
-def test_scaffold_github_actions_command_success_project(
+@responses.activate
+def test_scaffold_github_actions_command_success_project_serverless(
     dg_plus_cli_config,
 ):
+    mock_gql_response(
+        query=gql.DEPLOYMENT_INFO_QUERY,
+        json_data={"data": {"currentDeployment": {"agentType": "SERVERLESS"}}},
+    )
     with (
         ProxyRunner.test(use_fixed_test_components=True) as runner,
         isolated_example_project_foo_bar(runner),
@@ -113,16 +108,21 @@ def test_scaffold_github_actions_command_success_project(
         }
 
 
-def test_scaffold_github_actions_command_no_plus_config(
+@responses.activate
+def test_scaffold_github_actions_command_no_plus_config_serverless(
     setup_populated_git_workspace,
     monkeypatch,
 ):
+    mock_gql_response(
+        query=gql.DEPLOYMENT_INFO_QUERY,
+        json_data={"data": {"currentDeployment": {"agentType": "SERVERLESS"}}},
+    )
     with tempfile.TemporaryDirectory() as cloud_config_dir:
         monkeypatch.setenv("DG_CLI_CONFIG", str(Path(cloud_config_dir) / "dg.toml"))
         monkeypatch.setenv("DAGSTER_CLOUD_CLI_CONFIG", str(Path(cloud_config_dir) / "config"))
 
         runner = setup_populated_git_workspace
-        result = runner.invoke("scaffold", "github-actions", input="my-org\n")
+        result = runner.invoke("scaffold", "github-actions", input="my-org\nprod\nserverless\n")
         assert result.exit_code == 0, result.output + " " + str(result.exception)
 
         assert "Dagster Plus organization name: " in result.output
@@ -132,9 +132,14 @@ def test_scaffold_github_actions_command_no_plus_config(
         assert yaml.safe_load(Path("dagster_cloud.yaml").read_text()) == EXPECTED_DAGSTER_CLOUD_YAML
 
 
-def test_scaffold_github_actions_command_no_git_root(
+@responses.activate
+def test_scaffold_github_actions_command_no_git_root_serverless(
     dg_plus_cli_config,
 ):
+    mock_gql_response(
+        query=gql.DEPLOYMENT_INFO_QUERY,
+        json_data={"data": {"currentDeployment": {"agentType": "SERVERLESS"}}},
+    )
     with (
         ProxyRunner.test(use_fixed_test_components=True) as runner,
         isolated_example_workspace(runner),
@@ -154,3 +159,142 @@ def test_scaffold_github_actions_command_no_git_root(
         assert "hooli" in Path(".github/workflows/dagster-plus-deploy.yml").read_text()
         assert Path("dagster_cloud.yaml").exists()
         assert yaml.safe_load(Path("dagster_cloud.yaml").read_text()) == EXPECTED_DAGSTER_CLOUD_YAML
+
+
+FAKE_ECR_URL = "10000.dkr.ecr.us-east-1.amazonaws.com"
+
+
+def get_expected_dagster_cloud_yaml_hybrid(registry_url: str) -> dict:
+    return {
+        "locations": [
+            {
+                **location,
+                "build": {**location["build"], "registry": registry_url},
+            }
+            for location in EXPECTED_DAGSTER_CLOUD_YAML["locations"]
+        ]
+    }
+
+
+FAKE_REGISTRY_URLS = [
+    "10000.dkr.ecr.us-east-1.amazonaws.com/hooli",
+    "docker.io/hooli",
+    "ghcr.io/hooli",
+    "azurecr.io/hooli",
+    "gcr.io/hooli",
+]
+
+
+@responses.activate
+@pytest.mark.parametrize(
+    "registry_url, registry_info",
+    zip(FAKE_REGISTRY_URLS, REGISTRY_INFOS),
+    ids=[info.name for info in REGISTRY_INFOS],
+)
+def test_scaffold_github_actions_command_success_hybrid(
+    dg_plus_cli_config,
+    setup_populated_git_workspace: ProxyRunner,
+    registry_url,
+    registry_info,
+):
+    mock_gql_response(
+        query=gql.DEPLOYMENT_INFO_QUERY,
+        json_data={"data": {"currentDeployment": {"agentType": "HYBRID"}}},
+    )
+
+    Path("build.yaml").write_text(yaml.dump({"registry": registry_url}))
+
+    runner = setup_populated_git_workspace
+    result = runner.invoke("scaffold", "github-actions")
+    assert result.exit_code == 0, result.output + " " + str(result.exception)
+
+    assert Path(".github/workflows/dagster-plus-deploy.yml").exists()
+    assert "hooli" in Path(".github/workflows/dagster-plus-deploy.yml").read_text()
+    assert (
+        'Build and upload Docker image for "foo"'
+        in Path(".github/workflows/dagster-plus-deploy.yml").read_text()
+    )
+    assert (
+        'Build and upload Docker image for "bar"'
+        in Path(".github/workflows/dagster-plus-deploy.yml").read_text()
+    )
+    assert (
+        'Build and upload Docker image for "baz"'
+        in Path(".github/workflows/dagster-plus-deploy.yml").read_text()
+    )
+    assert Path("dagster_cloud.yaml").exists()
+    assert yaml.safe_load(
+        Path("dagster_cloud.yaml").read_text()
+    ) == get_expected_dagster_cloud_yaml_hybrid(registry_url)
+    assert "https://github.com/hooli/example-repo/settings/secrets/actions" in result.output
+
+    if registry_info.secrets_hints:
+        for hint in registry_info.secrets_hints:
+            assert hint in result.output
+
+
+@responses.activate
+def test_scaffold_github_actions_command_success_project_hybrid(
+    dg_plus_cli_config,
+):
+    mock_gql_response(
+        query=gql.DEPLOYMENT_INFO_QUERY,
+        json_data={"data": {"currentDeployment": {"agentType": "HYBRID"}}},
+    )
+    with (
+        ProxyRunner.test(use_fixed_test_components=True) as runner,
+        isolated_example_project_foo_bar(runner),
+    ):
+        Path("build.yaml").write_text(yaml.dump({"registry": FAKE_ECR_URL}))
+
+        subprocess.run(["git", "init"], check=False)
+        result = runner.invoke(
+            "scaffold",
+            "github-actions",
+        )
+        assert result.exit_code == 0, result.output + " " + str(result.exception)
+
+        assert Path(".github/workflows/dagster-plus-deploy.yml").exists()
+        assert "hooli" in Path(".github/workflows/dagster-plus-deploy.yml").read_text()
+        assert Path("dagster_cloud.yaml").exists()
+        assert yaml.safe_load(Path("dagster_cloud.yaml").read_text()) == {
+            "locations": [
+                {
+                    "build": {"directory": ".", "registry": FAKE_ECR_URL},
+                    "code_source": {"module_name": "foo_bar.definitions"},
+                    "location_name": "foo-bar",
+                }
+            ]
+        }
+
+
+@responses.activate
+def test_scaffold_github_actions_command_no_plus_config_hybrid(
+    setup_populated_git_workspace,
+    monkeypatch,
+):
+    mock_gql_response(
+        query=gql.DEPLOYMENT_INFO_QUERY,
+        json_data={"data": {"currentDeployment": {"agentType": "HYBRID"}}},
+    )
+    with tempfile.TemporaryDirectory() as cloud_config_dir:
+        monkeypatch.setenv("DG_CLI_CONFIG", str(Path(cloud_config_dir) / "dg.toml"))
+        monkeypatch.setenv("DAGSTER_CLOUD_CLI_CONFIG", str(Path(cloud_config_dir) / "config"))
+
+        Path("build.yaml").write_text(yaml.dump({"registry": FAKE_ECR_URL}))
+
+        runner = setup_populated_git_workspace
+        result = runner.invoke(
+            "scaffold",
+            "github-actions",
+            input="my-org\nprod\nhybrid\n",
+        )
+        assert result.exit_code == 0, result.output + " " + str(result.exception)
+
+        assert "Dagster Plus organization name: " in result.output
+        assert Path(".github/workflows/dagster-plus-deploy.yml").exists()
+        assert "my-org" in Path(".github/workflows/dagster-plus-deploy.yml").read_text()
+        assert Path("dagster_cloud.yaml").exists()
+        assert yaml.safe_load(
+            Path("dagster_cloud.yaml").read_text()
+        ) == get_expected_dagster_cloud_yaml_hybrid(FAKE_ECR_URL)


Reviews:
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-14T21:20:49Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T22:16:15Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T22:21:59Z
user: login: gibsondan
body: this looks sweet - the main todo is merging in 'registry.yaml' with 'build.yaml' i think - we should also maybe talk through exactly what gets templated when for the various artifacts that are needed for both local deploys and github action deploys (Dockerfile/build.yaml/empty container_context.yaml?)
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-15T15:17:37Z
user: login: mlarose
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T15:37:16Z
user: login: gibsondan
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T17:47:28Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T21:25:24Z
user: login: benpankow
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T21:32:51Z
user: login: benpankow
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T21:33:10Z
user: login: benpankow
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T21:33:26Z
user: login: benpankow
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T21:33:39Z
user: login: benpankow
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T21:37:39Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T21:57:19Z
user: login: gibsondan
body: couple of inline things but looks good - we'll definitely have to test the monorepo / workspace case and decide what we want the default build directory to be. Right now i have it building each project individually but that might not be what people want, especially if there is a shared utils folder or something.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-17T14:49:41Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T16:15:25Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T16:40:00Z

Review comments:
diff_hunk: @@ -207,26 +211,109 @@
 SERVERLESS_GITHUB_ACTION_FILE = (
     Path(__file__).parent.parent / "templates" / "serverless-github-action.yaml"
 )
+HYBRID_GITHUB_ACTION_FILE = Path(__file__).parent.parent / "templates" / "hybrid-github-action.yaml"
 
 
-def _generate_dagster_cloud_yaml_contents(
-    dg_context: DgContext, git_root: Path, cli_config: DgRawCliConfig
-) -> dict:
-    project_contexts = (
-        [
+BUILD_LOCATION_FRAGMENT = (
+    Path(__file__).parent.parent / "templates" / "build-location-fragment.yaml"
+)
+
+
+class ContainerRegistryInfo(NamedTuple):
+    name: str
+    match: Callable[[str], bool]
+    fragment: Path
+    secrets_hints: list[str]
+
+
+REGISTRY_INFOS = [
+    ContainerRegistryInfo(
+        name="ECR",
+        match=lambda url: "ecr" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "ecr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AWS_ACCESS_KEY_ID --body "(your AWS access key ID)"',
+            'gh secret set AWS_SECRET_ACCESS_KEY --body "(your AWS secret access key)"',
+            'gh secret set AWS_REGION --body "(your AWS region)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="DockerHub",
+        match=lambda url: "docker.io" in url,
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: github-advanced-security[bot]
body: ## Incomplete URL substring sanitization

The string [docker.io](1) may be at an arbitrary position in the sanitized URL.

[Show more details](https://github.com/dagster-io/dagster/security/code-scanning/85)
created_at: 2025-04-14T21:20:49Z
author_association: NONE
diff_hunk: @@ -207,26 +211,109 @@
 SERVERLESS_GITHUB_ACTION_FILE = (
     Path(__file__).parent.parent / "templates" / "serverless-github-action.yaml"
 )
+HYBRID_GITHUB_ACTION_FILE = Path(__file__).parent.parent / "templates" / "hybrid-github-action.yaml"
 
 
-def _generate_dagster_cloud_yaml_contents(
-    dg_context: DgContext, git_root: Path, cli_config: DgRawCliConfig
-) -> dict:
-    project_contexts = (
-        [
+BUILD_LOCATION_FRAGMENT = (
+    Path(__file__).parent.parent / "templates" / "build-location-fragment.yaml"
+)
+
+
+class ContainerRegistryInfo(NamedTuple):
+    name: str
+    match: Callable[[str], bool]
+    fragment: Path
+    secrets_hints: list[str]
+
+
+REGISTRY_INFOS = [
+    ContainerRegistryInfo(
+        name="ECR",
+        match=lambda url: "ecr" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "ecr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AWS_ACCESS_KEY_ID --body "(your AWS access key ID)"',
+            'gh secret set AWS_SECRET_ACCESS_KEY --body "(your AWS secret access key)"',
+            'gh secret set AWS_REGION --body "(your AWS region)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="DockerHub",
+        match=lambda url: "docker.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "dockerhub-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set DOCKERHUB_USERNAME --body "(your DockerHub username)"',
+            'gh secret set DOCKERHUB_TOKEN --body "(your DockerHub token)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="GitHub Container Registry",
+        match=lambda url: "ghcr.io" in url,
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: github-advanced-security[bot]
body: ## Incomplete URL substring sanitization

The string [ghcr.io](1) may be at an arbitrary position in the sanitized URL.

[Show more details](https://github.com/dagster-io/dagster/security/code-scanning/86)
created_at: 2025-04-14T21:20:49Z
author_association: NONE
diff_hunk: @@ -207,26 +211,109 @@
 SERVERLESS_GITHUB_ACTION_FILE = (
     Path(__file__).parent.parent / "templates" / "serverless-github-action.yaml"
 )
+HYBRID_GITHUB_ACTION_FILE = Path(__file__).parent.parent / "templates" / "hybrid-github-action.yaml"
 
 
-def _generate_dagster_cloud_yaml_contents(
-    dg_context: DgContext, git_root: Path, cli_config: DgRawCliConfig
-) -> dict:
-    project_contexts = (
-        [
+BUILD_LOCATION_FRAGMENT = (
+    Path(__file__).parent.parent / "templates" / "build-location-fragment.yaml"
+)
+
+
+class ContainerRegistryInfo(NamedTuple):
+    name: str
+    match: Callable[[str], bool]
+    fragment: Path
+    secrets_hints: list[str]
+
+
+REGISTRY_INFOS = [
+    ContainerRegistryInfo(
+        name="ECR",
+        match=lambda url: "ecr" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "ecr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AWS_ACCESS_KEY_ID --body "(your AWS access key ID)"',
+            'gh secret set AWS_SECRET_ACCESS_KEY --body "(your AWS secret access key)"',
+            'gh secret set AWS_REGION --body "(your AWS region)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="DockerHub",
+        match=lambda url: "docker.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "dockerhub-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set DOCKERHUB_USERNAME --body "(your DockerHub username)"',
+            'gh secret set DOCKERHUB_TOKEN --body "(your DockerHub token)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="GitHub Container Registry",
+        match=lambda url: "ghcr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "github-container-registry-login-fragment.yaml",
+        secrets_hints=[],
+    ),
+    ContainerRegistryInfo(
+        name="Azure Container Registry",
+        match=lambda url: "azurecr.io" in url,
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: github-advanced-security[bot]
body: ## Incomplete URL substring sanitization

The string [azurecr.io](1) may be at an arbitrary position in the sanitized URL.

[Show more details](https://github.com/dagster-io/dagster/security/code-scanning/87)
created_at: 2025-04-14T21:20:49Z
author_association: NONE
diff_hunk: @@ -207,26 +211,109 @@
 SERVERLESS_GITHUB_ACTION_FILE = (
     Path(__file__).parent.parent / "templates" / "serverless-github-action.yaml"
 )
+HYBRID_GITHUB_ACTION_FILE = Path(__file__).parent.parent / "templates" / "hybrid-github-action.yaml"
 
 
-def _generate_dagster_cloud_yaml_contents(
-    dg_context: DgContext, git_root: Path, cli_config: DgRawCliConfig
-) -> dict:
-    project_contexts = (
-        [
+BUILD_LOCATION_FRAGMENT = (
+    Path(__file__).parent.parent / "templates" / "build-location-fragment.yaml"
+)
+
+
+class ContainerRegistryInfo(NamedTuple):
+    name: str
+    match: Callable[[str], bool]
+    fragment: Path
+    secrets_hints: list[str]
+
+
+REGISTRY_INFOS = [
+    ContainerRegistryInfo(
+        name="ECR",
+        match=lambda url: "ecr" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "ecr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AWS_ACCESS_KEY_ID --body "(your AWS access key ID)"',
+            'gh secret set AWS_SECRET_ACCESS_KEY --body "(your AWS secret access key)"',
+            'gh secret set AWS_REGION --body "(your AWS region)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="DockerHub",
+        match=lambda url: "docker.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "dockerhub-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set DOCKERHUB_USERNAME --body "(your DockerHub username)"',
+            'gh secret set DOCKERHUB_TOKEN --body "(your DockerHub token)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="GitHub Container Registry",
+        match=lambda url: "ghcr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "github-container-registry-login-fragment.yaml",
+        secrets_hints=[],
+    ),
+    ContainerRegistryInfo(
+        name="Azure Container Registry",
+        match=lambda url: "azurecr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "azure-container-registry-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AZURE_CLIENT_ID --body "(your Azure client ID)"',
+            'gh secret set AZURE_CLIENT_SECRET --body "(your Azure client secret)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="Google Container Registry",
+        match=lambda url: "gcr.io" in url,
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: github-advanced-security[bot]
body: ## Incomplete URL substring sanitization

The string [gcr.io](1) may be at an arbitrary position in the sanitized URL.

[Show more details](https://github.com/dagster-io/dagster/security/code-scanning/88)
created_at: 2025-04-14T21:20:49Z
author_association: NONE
diff_hunk: @@ -1,39 +1,25 @@
 import subprocess
 import tempfile
 from pathlib import Path
-from unittest import mock
 
 import pytest
 import yaml
+from dagster_dg.cli.scaffold import REGISTRY_INFOS
 from dagster_dg.utils import ensure_dagster_dg_tests_import
+from dagster_dg.utils.plus import gql
 
 ensure_dagster_dg_tests_import()
 
+from dagster_dg_tests.cli_tests.plus_tests.utils import mock_gql_response
 from dagster_dg_tests.utils import (
     ProxyRunner,
     isolated_example_project_foo_bar,
     isolated_example_workspace,
 )
 
+ensure_dagster_dg_tests_import()
path: python_modules/libraries/dagster-dg/dagster_dg_tests/cli_tests/plus_tests/test_scaffold_github_actions_command.py
user: login: graphite-app[bot]
body: There's a duplicate call to `ensure_dagster_dg_tests_import()` on line 20, which is redundant since it's already called on line 11. The second call should be removed to avoid unnecessary execution.
```suggestion
 
 ensure_dagster_dg_tests_import()
 
from dagster_dg_tests.cli_tests.plus_tests.utils import mock_gql_response
 from dagster_dg_tests.utils import (
     ProxyRunner,
     isolated_example_project_foo_bar,
     isolated_example_workspace,
 )
 
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-14T22:16:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -207,26 +212,109 @@ def _search_for_git_root(path: Path) -> Optional[Path]:
 SERVERLESS_GITHUB_ACTION_FILE = (
     Path(__file__).parent.parent / "templates" / "serverless-github-action.yaml"
 )
+HYBRID_GITHUB_ACTION_FILE = Path(__file__).parent.parent / "templates" / "hybrid-github-action.yaml"
 
 
-def _generate_dagster_cloud_yaml_contents(
-    dg_context: DgContext, git_root: Path, cli_config: DgRawCliConfig
-) -> dict:
-    project_contexts = (
-        [
+BUILD_LOCATION_FRAGMENT = (
+    Path(__file__).parent.parent / "templates" / "build-location-fragment.yaml"
+)
+
+
+class ContainerRegistryInfo(NamedTuple):
+    name: str
+    match: Callable[[str], bool]
+    fragment: Path
+    secrets_hints: list[str]
+
+
+REGISTRY_INFOS = [
+    ContainerRegistryInfo(
+        name="ECR",
+        match=lambda url: "ecr" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "ecr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AWS_ACCESS_KEY_ID --body "(your AWS access key ID)"',
+            'gh secret set AWS_SECRET_ACCESS_KEY --body "(your AWS secret access key)"',
+            'gh secret set AWS_REGION --body "(your AWS region)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="DockerHub",
+        match=lambda url: "docker.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "dockerhub-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set DOCKERHUB_USERNAME --body "(your DockerHub username)"',
+            'gh secret set DOCKERHUB_TOKEN --body "(your DockerHub token)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="GitHub Container Registry",
+        match=lambda url: "ghcr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "github-container-registry-login-fragment.yaml",
+        secrets_hints=[],
+    ),
+    ContainerRegistryInfo(
+        name="Azure Container Registry",
+        match=lambda url: "azurecr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "azure-container-registry-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AZURE_CLIENT_ID --body "(your Azure client ID)"',
+            'gh secret set AZURE_CLIENT_SECRET --body "(your Azure client secret)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="Google Container Registry",
+        match=lambda url: "gcr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "gcr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set GCR_JSON_KEY --body "(your GCR JSON key)"',
+        ],
+    ),
+]
+
+
+def _get_deployment_agent_type(gql_client: DagsterPlusGraphQLClient) -> bool:
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: graphite-app[bot]
body: The return type annotation should be `str` instead of `bool` since this function returns the agent type as a string (`'SERVERLESS'` or `'HYBRID'`).
```suggestion
def _get_deployment_agent_type(gql_client: DagsterPlusGraphQLClient) -> str:
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-14T22:21:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -276,23 +390,74 @@ def scaffold_github_actions_command(git_root: Optional[Path], **global_options:
     workflows_dir = git_root / ".github" / "workflows"
     workflows_dir.mkdir(parents=True, exist_ok=True)
 
-    template = SERVERLESS_GITHUB_ACTION_FILE.read_text()
-
     if plus_config and plus_config.organization:
         organization_name = plus_config.organization
         click.echo(f"Using organization name {organization_name} from Dagster Plus config.")
     else:
         organization_name = click.prompt("Dagster Plus organization name") or ""
 
+    if plus_config:
+        gql_client = DagsterPlusGraphQLClient.from_config(plus_config)
+
+        agent_type = _get_deployment_agent_type(gql_client)
+
+    else:
+        click.echo("No Dagster Plus config found.")
+        agent_type = click.prompt(
+            "Deployment agent type: ", type=click.Choice(("serverless", "hybrid"))
+        ).upper()
+
+    if agent_type == "SERVERLESS":
+        click.echo("Using serverless workflow template.")
+    else:
+        click.echo("Using hybrid workflow template.")
+
+    registry_url = None
+    additional_secrets_hints = []
+
+    template = (
+        SERVERLESS_GITHUB_ACTION_FILE.read_text()
+        if agent_type == "SERVERLESS"
+        else HYBRID_GITHUB_ACTION_FILE.read_text()
+    )
     template = template.replace("ORGANIZATION_NAME", organization_name)
 
+    if agent_type == "HYBRID":
+        # Attempt to read registry from registry.yaml
+        registry_file = dg_context.root_path / "registry.yaml"
+        if registry_file.exists():
+            registry_url = yaml.safe_load(registry_file.read_text()).get("registry")
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: gibsondan
body: i think we need to reconcile this with build.yaml - "registry" can be a dict there if we want, it doesn't need to match the dagster_cloud.yaml format exactly
created_at: 2025-04-15T15:08:15Z
author_association: MEMBER
diff_hunk: @@ -276,23 +390,74 @@ def scaffold_github_actions_command(git_root: Optional[Path], **global_options:
     workflows_dir = git_root / ".github" / "workflows"
     workflows_dir.mkdir(parents=True, exist_ok=True)
 
-    template = SERVERLESS_GITHUB_ACTION_FILE.read_text()
-
     if plus_config and plus_config.organization:
         organization_name = plus_config.organization
         click.echo(f"Using organization name {organization_name} from Dagster Plus config.")
     else:
         organization_name = click.prompt("Dagster Plus organization name") or ""
 
+    if plus_config:
+        gql_client = DagsterPlusGraphQLClient.from_config(plus_config)
+
+        agent_type = _get_deployment_agent_type(gql_client)
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: gibsondan
body: i know we have the 'default' deployment in the config but do we want to let them override it in the CLI? in which case this would maybe need to explicitly pass in the deployment in some way
created_at: 2025-04-15T15:10:29Z
author_association: MEMBER
diff_hunk: @@ -207,26 +212,109 @@ def _search_for_git_root(path: Path) -> Optional[Path]:
 SERVERLESS_GITHUB_ACTION_FILE = (
     Path(__file__).parent.parent / "templates" / "serverless-github-action.yaml"
 )
+HYBRID_GITHUB_ACTION_FILE = Path(__file__).parent.parent / "templates" / "hybrid-github-action.yaml"
 
 
-def _generate_dagster_cloud_yaml_contents(
-    dg_context: DgContext, git_root: Path, cli_config: DgRawCliConfig
-) -> dict:
-    project_contexts = (
-        [
+BUILD_LOCATION_FRAGMENT = (
+    Path(__file__).parent.parent / "templates" / "build-location-fragment.yaml"
+)
+
+
+class ContainerRegistryInfo(NamedTuple):
+    name: str
+    match: Callable[[str], bool]
+    fragment: Path
+    secrets_hints: list[str]
+
+
+REGISTRY_INFOS = [
+    ContainerRegistryInfo(
+        name="ECR",
+        match=lambda url: "ecr" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "ecr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AWS_ACCESS_KEY_ID --body "(your AWS access key ID)"',
+            'gh secret set AWS_SECRET_ACCESS_KEY --body "(your AWS secret access key)"',
+            'gh secret set AWS_REGION --body "(your AWS region)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="DockerHub",
+        match=lambda url: "docker.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "dockerhub-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set DOCKERHUB_USERNAME --body "(your DockerHub username)"',
+            'gh secret set DOCKERHUB_TOKEN --body "(your DockerHub token)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="GitHub Container Registry",
+        match=lambda url: "ghcr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "github-container-registry-login-fragment.yaml",
+        secrets_hints=[],
+    ),
+    ContainerRegistryInfo(
+        name="Azure Container Registry",
+        match=lambda url: "azurecr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "azure-container-registry-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AZURE_CLIENT_ID --body "(your Azure client ID)"',
+            'gh secret set AZURE_CLIENT_SECRET --body "(your Azure client secret)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="Google Container Registry",
+        match=lambda url: "gcr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "gcr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set GCR_JSON_KEY --body "(your GCR JSON key)"',
+        ],
+    ),
+]
+
+
+def _get_deployment_agent_type(gql_client: DagsterPlusGraphQLClient) -> str:
+    result = gql_client.execute(gql.DEPLOYMENT_INFO_QUERY)
+    return result["currentDeployment"]["agentType"]
+
+
+def _get_project_contexts(dg_context: DgContext, cli_config: DgRawCliConfig) -> list[DgContext]:
+    if dg_context.is_workspace:
+        return [
             dg_context.for_project_environment(project.path, cli_config)
             for project in dg_context.project_specs
         ]
-        if dg_context.is_workspace
-        else [dg_context]
-    )
+    else:
+        return [dg_context]
+
+
+def _generate_dagster_cloud_yaml_contents(
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: gibsondan
body: this is going to be pretty short lived right? not sure if we should just skip straight ahead to having the github action use dg commands? Also fine with this as an intermediate milestone, i just think we'll end up removing the scaffolded dagster_cloud.yaml almost immediately (like this week)
created_at: 2025-04-15T15:11:37Z
author_association: MEMBER
diff_hunk: @@ -276,23 +390,74 @@ def scaffold_github_actions_command(git_root: Optional[Path], **global_options:
     workflows_dir = git_root / ".github" / "workflows"
     workflows_dir.mkdir(parents=True, exist_ok=True)
 
-    template = SERVERLESS_GITHUB_ACTION_FILE.read_text()
-
     if plus_config and plus_config.organization:
         organization_name = plus_config.organization
         click.echo(f"Using organization name {organization_name} from Dagster Plus config.")
     else:
         organization_name = click.prompt("Dagster Plus organization name") or ""
 
+    if plus_config:
+        gql_client = DagsterPlusGraphQLClient.from_config(plus_config)
+
+        agent_type = _get_deployment_agent_type(gql_client)
+
+    else:
+        click.echo("No Dagster Plus config found.")
+        agent_type = click.prompt(
+            "Deployment agent type: ", type=click.Choice(("serverless", "hybrid"))
+        ).upper()
+
+    if agent_type == "SERVERLESS":
+        click.echo("Using serverless workflow template.")
+    else:
+        click.echo("Using hybrid workflow template.")
+
+    registry_url = None
+    additional_secrets_hints = []
+
+    template = (
+        SERVERLESS_GITHUB_ACTION_FILE.read_text()
+        if agent_type == "SERVERLESS"
+        else HYBRID_GITHUB_ACTION_FILE.read_text()
+    )
     template = template.replace("ORGANIZATION_NAME", organization_name)
 
+    if agent_type == "HYBRID":
+        # Attempt to read registry from registry.yaml
+        registry_file = dg_context.root_path / "registry.yaml"
+        if registry_file.exists():
+            registry_url = yaml.safe_load(registry_file.read_text()).get("registry")
+
+        if not registry_url:
+            registry_url = click.prompt("Docker registry URL, for built images")
+        else:
+            click.echo(f"Using Docker registry URL {registry_url} from registry.yaml")
+
+        project_contexts = _get_project_contexts(dg_context, cli_config)
+        for location_ctx in project_contexts:
+            _create_deploy_dockerfile(location_ctx.root_path / "Dockerfile", "3.11")
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: gibsondan
body: Note that dg deploy also scaffolds a Dockerfile.. Lots of dockerfile scaffolding happening, should we have a separate command for it instead? maybe something that also scaffolds the build.yaml etc. that you would need to deploy either locally or via CI/CD?
created_at: 2025-04-15T15:13:47Z
author_association: MEMBER
diff_hunk: @@ -247,6 +335,32 @@ def _get_git_web_url(git_root: Path) -> Optional[str]:
         return None
 
 
+def _create_deploy_dockerfile(dst_path, python_version):
+    dockerfile_template_path = os.path.join(
+        os.path.dirname(__file__), "..", "templates", "deploy_uv_Dockerfile.jinja"
+    )
+
+    loader = jinja2.FileSystemLoader(searchpath=os.path.dirname(dockerfile_template_path))
+    env = jinja2.Environment(loader=loader)
+
+    template = env.get_template(os.path.basename(dockerfile_template_path))
+
+    with open(dst_path, "w", encoding="utf8") as f:
+        f.write(template.render(python_version=python_version))
+        f.write("\n")
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: gibsondan
body: there's existing code that does this in dg deploy too, we should reconcile
created_at: 2025-04-15T15:14:31Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,16 @@
+- name: Build and upload Docker image for "LOCATION_NAME"
+  if: steps.prerun.outputs.result != 'skip'
+  uses: docker/build-push-action@v4
+  with:
+    context: LOCATION_PATH
+    push: true
+    tags: ${{ env.IMAGE_REGISTRY }}:${{ env.IMAGE_TAG }}-LOCATION_NAME
+    cache-from: type=gha
+    cache-to: type=gha,mode=max
+
+- name: Update build session with image tag for LOCATION_NAME
+  id: ci-set-build-output-example-location
+  if: steps.prerun.outputs.result != 'skip'
+  uses: dagster-io/dagster-cloud-action/actions/utils/dagster-cloud-cli@v0.1
path: python_modules/libraries/dagster-dg/dagster_dg/templates/build-location-fragment.yaml
user: login: gibsondan
body: at some point fairly soon we will likely template in a specific version rather than v0.1 (to start, probably some other constant version defined in code based on what @mlarose gives us - eventually, maybe the version that matches the dg version?) - we could add that templating now or later
created_at: 2025-04-15T15:16:04Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,107 @@
+name: Dagster Cloud Hybrid Deployment
+on:
+  push: # For full deployments
+    branches:
+      - "main"
+      - "master"
+  pull_request: # For branch deployments
+    types: [opened, synchronize, reopened, closed]
+
+concurrency:
+  # Cancel in-progress deploys to the same branch
+  group: ${{ github.ref }}-${{ github.event_name }}
+  cancel-in-progress: true
+env:
+  # The organization name in Dagster Cloud
+  DAGSTER_CLOUD_ORGANIZATION: "ORGANIZATION_NAME"
+  # The API token from https://dagster.cloud/ should be stored in Secrets
+  DAGSTER_CLOUD_API_TOKEN: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+  # Path to the root folder containing the dagster project
+  DAGSTER_PROJECT_DIR: "."
+  # Path to dagster_cloud.yaml relative to DAGSTER_PROJECT_DIR
+  DAGSTER_CLOUD_YAML_PATH: "dagster_cloud.yaml"
+  # The IMAGE_REGISTRY should match the 'registry:'' in dagster_cloud.yaml
+  IMAGE_REGISTRY: "IMAGE_REGISTRY_TEMPLATE"
+  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+jobs:
+  dagster-cloud-deploy:
+    runs-on: ubuntu-22.04
+    steps:
+      # If this is a closed PR the prerun step closes the branch deployment and returns
+      # output.result='skip' which is used to skip other steps in this workflow.
+      - name: Pre-run checks
+        id: prerun
+        uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v0.1
+
+      # Checkout the project
+      - name: Checkout
+        uses: actions/checkout@v3
+        if: steps.prerun.outputs.result != 'skip'
+        with:
+          ref: ${{ github.head_ref }}
+
+      # Validate dagster_cloud.yaml and the connection to dagster.cloud
+      - name: Validate configuration
+        id: ci-validate
+        if: steps.prerun.outputs.result != 'skip'
+        uses: dagster-io/dagster-cloud-action/actions/utils/dagster-cloud-cli@v0.1
+        with:
+          command: "ci check --project-dir ${{ env.DAGSTER_PROJECT_DIR }} --dagster-cloud-yaml-path ${{ env.DAGSTER_CLOUD_YAML_PATH }}"
+
+      # Parse dagster_cloud.yaml, detect if this is branch deployment and initialize the build session
+      - name: Initialize build session
+        id: ci-init
+        if: steps.prerun.outputs.result != 'skip'
+        uses: dagster-io/dagster-cloud-action/actions/utils/ci-init@v0.1
+        with:
+          project_dir: ${{ env.DAGSTER_PROJECT_DIR }}
+          dagster_cloud_yaml_path: ${{ env.DAGSTER_CLOUD_YAML_PATH }}
+          # A full deployment name. If this run is for a pull request, this value will be used as
+          # the base deployment for the branch deployment.
+          deployment: "prod"
path: python_modules/libraries/dagster-dg/dagster_dg/templates/hybrid-github-action.yaml
user: login: gibsondan
body: surely this should be templated too?
created_at: 2025-04-15T15:16:31Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,16 @@
+- name: Build and upload Docker image for "LOCATION_NAME"
+  if: steps.prerun.outputs.result != 'skip'
+  uses: docker/build-push-action@v4
+  with:
+    context: LOCATION_PATH
+    push: true
+    tags: ${{ env.IMAGE_REGISTRY }}:${{ env.IMAGE_TAG }}-LOCATION_NAME
+    cache-from: type=gha
+    cache-to: type=gha,mode=max
+
+- name: Update build session with image tag for LOCATION_NAME
+  id: ci-set-build-output-example-location
+  if: steps.prerun.outputs.result != 'skip'
+  uses: dagster-io/dagster-cloud-action/actions/utils/dagster-cloud-cli@v0.1
path: python_modules/libraries/dagster-dg/dagster_dg/templates/build-location-fragment.yaml
user: login: mlarose
body: How about I start by creating a long-running branch called dg-alpha or something like that and use the branch name as a target? so we can commit update without too much manual labor?
created_at: 2025-04-15T15:37:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,16 @@
+- name: Build and upload Docker image for "LOCATION_NAME"
+  if: steps.prerun.outputs.result != 'skip'
+  uses: docker/build-push-action@v4
+  with:
+    context: LOCATION_PATH
+    push: true
+    tags: ${{ env.IMAGE_REGISTRY }}:${{ env.IMAGE_TAG }}-LOCATION_NAME
+    cache-from: type=gha
+    cache-to: type=gha,mode=max
+
+- name: Update build session with image tag for LOCATION_NAME
+  id: ci-set-build-output-example-location
+  if: steps.prerun.outputs.result != 'skip'
+  uses: dagster-io/dagster-cloud-action/actions/utils/dagster-cloud-cli@v0.1
path: python_modules/libraries/dagster-dg/dagster_dg/templates/build-location-fragment.yaml
user: login: gibsondan
body: Anything that lets us experiment with a PEX to start is fine by me. Keep in mind that real users may be using this as soon as next week though, so it won't be alpha for long
created_at: 2025-04-15T17:47:28Z
author_association: MEMBER
diff_hunk: @@ -238,16 +340,35 @@ def _generate_dagster_cloud_yaml_contents(
 def _get_git_web_url(git_root: Path) -> Optional[str]:
     try:
         remote_origin_url = (
-            subprocess.check_output(["git", "config", "remote.origin.url"]).decode("utf-8").strip()
+            subprocess.check_output(["git", "config", "remote.origin.url"])
+            .decode("utf-8")
+            .strip()
         )
         remote_origin_url = (
-            remote_origin_url.replace(":", "/").replace(".git", "").replace("git@", "https://")
+            remote_origin_url.replace(":", "/")
+            .replace(".git", "")
+            .replace("git@", "https://")
         )
         return remote_origin_url
     except subprocess.CalledProcessError:
         return None
 
 
+def _get_build_fragment_for_locations(
+    location_ctxs: list[DgContext], git_root: Path
+) -> str:
+    # TODO: when we cut over to dg deploy, we'll just use a single build call for the workspace
+    # rather than iterating over each project
+    output = []
+    for location_ctx in location_ctxs:
+        output.append(
+            textwrap.indent(BUILD_LOCATION_FRAGMENT.read_text(), " " * 6)
+            .replace("LOCATION_NAME", location_ctx.code_location_name)
+            .replace("LOCATION_PATH", str(location_ctx.root_path.relative_to(git_root)))
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: graphite-app[bot]
body: The `relative_to()` call assumes that `location_ctx.root_path` is always within `git_root`. This will raise a `ValueError` if a location path exists outside the git repository. Consider adding error handling to gracefully manage cases where locations aren't within the git repository, perhaps by using a different path resolution approach or providing a helpful error message.
```suggestion
            textwrap.indent(BUILD_LOCATION_FRAGMENT.read_text(), " " * 6)
            .replace("LOCATION_NAME", location_ctx.code_location_name)
            .replace(
                "LOCATION_PATH",
                str(
                    location_ctx.root_path.relative_to(git_root)
                    if location_ctx.root_path.is_relative_to(git_root)
                    else location_ctx.root_path
                ),
            )
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-16T21:25:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -276,23 +390,74 @@ def scaffold_github_actions_command(git_root: Optional[Path], **global_options:
     workflows_dir = git_root / ".github" / "workflows"
     workflows_dir.mkdir(parents=True, exist_ok=True)
 
-    template = SERVERLESS_GITHUB_ACTION_FILE.read_text()
-
     if plus_config and plus_config.organization:
         organization_name = plus_config.organization
         click.echo(f"Using organization name {organization_name} from Dagster Plus config.")
     else:
         organization_name = click.prompt("Dagster Plus organization name") or ""
 
+    if plus_config:
+        gql_client = DagsterPlusGraphQLClient.from_config(plus_config)
+
+        agent_type = _get_deployment_agent_type(gql_client)
+
+    else:
+        click.echo("No Dagster Plus config found.")
+        agent_type = click.prompt(
+            "Deployment agent type: ", type=click.Choice(("serverless", "hybrid"))
+        ).upper()
+
+    if agent_type == "SERVERLESS":
+        click.echo("Using serverless workflow template.")
+    else:
+        click.echo("Using hybrid workflow template.")
+
+    registry_url = None
+    additional_secrets_hints = []
+
+    template = (
+        SERVERLESS_GITHUB_ACTION_FILE.read_text()
+        if agent_type == "SERVERLESS"
+        else HYBRID_GITHUB_ACTION_FILE.read_text()
+    )
     template = template.replace("ORGANIZATION_NAME", organization_name)
 
+    if agent_type == "HYBRID":
+        # Attempt to read registry from registry.yaml
+        registry_file = dg_context.root_path / "registry.yaml"
+        if registry_file.exists():
+            registry_url = yaml.safe_load(registry_file.read_text()).get("registry")
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: benpankow
body: Alright, updated to match `build.yaml`
created_at: 2025-04-16T21:32:51Z
author_association: MEMBER
diff_hunk: @@ -276,23 +390,74 @@ def scaffold_github_actions_command(git_root: Optional[Path], **global_options:
     workflows_dir = git_root / ".github" / "workflows"
     workflows_dir.mkdir(parents=True, exist_ok=True)
 
-    template = SERVERLESS_GITHUB_ACTION_FILE.read_text()
-
     if plus_config and plus_config.organization:
         organization_name = plus_config.organization
         click.echo(f"Using organization name {organization_name} from Dagster Plus config.")
     else:
         organization_name = click.prompt("Dagster Plus organization name") or ""
 
+    if plus_config:
+        gql_client = DagsterPlusGraphQLClient.from_config(plus_config)
+
+        agent_type = _get_deployment_agent_type(gql_client)
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: benpankow
body: Yes, I think it maybe makes sense to handle this in a follow up?
created_at: 2025-04-16T21:33:10Z
author_association: MEMBER
diff_hunk: @@ -207,26 +212,109 @@ def _search_for_git_root(path: Path) -> Optional[Path]:
 SERVERLESS_GITHUB_ACTION_FILE = (
     Path(__file__).parent.parent / "templates" / "serverless-github-action.yaml"
 )
+HYBRID_GITHUB_ACTION_FILE = Path(__file__).parent.parent / "templates" / "hybrid-github-action.yaml"
 
 
-def _generate_dagster_cloud_yaml_contents(
-    dg_context: DgContext, git_root: Path, cli_config: DgRawCliConfig
-) -> dict:
-    project_contexts = (
-        [
+BUILD_LOCATION_FRAGMENT = (
+    Path(__file__).parent.parent / "templates" / "build-location-fragment.yaml"
+)
+
+
+class ContainerRegistryInfo(NamedTuple):
+    name: str
+    match: Callable[[str], bool]
+    fragment: Path
+    secrets_hints: list[str]
+
+
+REGISTRY_INFOS = [
+    ContainerRegistryInfo(
+        name="ECR",
+        match=lambda url: "ecr" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "ecr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AWS_ACCESS_KEY_ID --body "(your AWS access key ID)"',
+            'gh secret set AWS_SECRET_ACCESS_KEY --body "(your AWS secret access key)"',
+            'gh secret set AWS_REGION --body "(your AWS region)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="DockerHub",
+        match=lambda url: "docker.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "dockerhub-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set DOCKERHUB_USERNAME --body "(your DockerHub username)"',
+            'gh secret set DOCKERHUB_TOKEN --body "(your DockerHub token)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="GitHub Container Registry",
+        match=lambda url: "ghcr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "github-container-registry-login-fragment.yaml",
+        secrets_hints=[],
+    ),
+    ContainerRegistryInfo(
+        name="Azure Container Registry",
+        match=lambda url: "azurecr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "azure-container-registry-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AZURE_CLIENT_ID --body "(your Azure client ID)"',
+            'gh secret set AZURE_CLIENT_SECRET --body "(your Azure client secret)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="Google Container Registry",
+        match=lambda url: "gcr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "gcr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set GCR_JSON_KEY --body "(your GCR JSON key)"',
+        ],
+    ),
+]
+
+
+def _get_deployment_agent_type(gql_client: DagsterPlusGraphQLClient) -> str:
+    result = gql_client.execute(gql.DEPLOYMENT_INFO_QUERY)
+    return result["currentDeployment"]["agentType"]
+
+
+def _get_project_contexts(dg_context: DgContext, cli_config: DgRawCliConfig) -> list[DgContext]:
+    if dg_context.is_workspace:
+        return [
             dg_context.for_project_environment(project.path, cli_config)
             for project in dg_context.project_specs
         ]
-        if dg_context.is_workspace
-        else [dg_context]
-    )
+    else:
+        return [dg_context]
+
+
+def _generate_dagster_cloud_yaml_contents(
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: benpankow
body: Yeah, I think this'll be the case as well. Will handle in follow-up.
created_at: 2025-04-16T21:33:26Z
author_association: MEMBER
diff_hunk: @@ -276,23 +390,74 @@ def scaffold_github_actions_command(git_root: Optional[Path], **global_options:
     workflows_dir = git_root / ".github" / "workflows"
     workflows_dir.mkdir(parents=True, exist_ok=True)
 
-    template = SERVERLESS_GITHUB_ACTION_FILE.read_text()
-
     if plus_config and plus_config.organization:
         organization_name = plus_config.organization
         click.echo(f"Using organization name {organization_name} from Dagster Plus config.")
     else:
         organization_name = click.prompt("Dagster Plus organization name") or ""
 
+    if plus_config:
+        gql_client = DagsterPlusGraphQLClient.from_config(plus_config)
+
+        agent_type = _get_deployment_agent_type(gql_client)
+
+    else:
+        click.echo("No Dagster Plus config found.")
+        agent_type = click.prompt(
+            "Deployment agent type: ", type=click.Choice(("serverless", "hybrid"))
+        ).upper()
+
+    if agent_type == "SERVERLESS":
+        click.echo("Using serverless workflow template.")
+    else:
+        click.echo("Using hybrid workflow template.")
+
+    registry_url = None
+    additional_secrets_hints = []
+
+    template = (
+        SERVERLESS_GITHUB_ACTION_FILE.read_text()
+        if agent_type == "SERVERLESS"
+        else HYBRID_GITHUB_ACTION_FILE.read_text()
+    )
     template = template.replace("ORGANIZATION_NAME", organization_name)
 
+    if agent_type == "HYBRID":
+        # Attempt to read registry from registry.yaml
+        registry_file = dg_context.root_path / "registry.yaml"
+        if registry_file.exists():
+            registry_url = yaml.safe_load(registry_file.read_text()).get("registry")
+
+        if not registry_url:
+            registry_url = click.prompt("Docker registry URL, for built images")
+        else:
+            click.echo(f"Using Docker registry URL {registry_url} from registry.yaml")
+
+        project_contexts = _get_project_contexts(dg_context, cli_config)
+        for location_ctx in project_contexts:
+            _create_deploy_dockerfile(location_ctx.root_path / "Dockerfile", "3.11")
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: benpankow
body: Moved to shared util, but follow up will add a separate command for this
created_at: 2025-04-16T21:33:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,107 @@
+name: Dagster Cloud Hybrid Deployment
+on:
+  push: # For full deployments
+    branches:
+      - "main"
+      - "master"
+  pull_request: # For branch deployments
+    types: [opened, synchronize, reopened, closed]
+
+concurrency:
+  # Cancel in-progress deploys to the same branch
+  group: ${{ github.ref }}-${{ github.event_name }}
+  cancel-in-progress: true
+env:
+  # The organization name in Dagster Cloud
+  DAGSTER_CLOUD_ORGANIZATION: "ORGANIZATION_NAME"
+  # The API token from https://dagster.cloud/ should be stored in Secrets
+  DAGSTER_CLOUD_API_TOKEN: ${{ secrets.DAGSTER_CLOUD_API_TOKEN }}
+  # Path to the root folder containing the dagster project
+  DAGSTER_PROJECT_DIR: "."
+  # Path to dagster_cloud.yaml relative to DAGSTER_PROJECT_DIR
+  DAGSTER_CLOUD_YAML_PATH: "dagster_cloud.yaml"
+  # The IMAGE_REGISTRY should match the 'registry:'' in dagster_cloud.yaml
+  IMAGE_REGISTRY: "IMAGE_REGISTRY_TEMPLATE"
+  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+jobs:
+  dagster-cloud-deploy:
+    runs-on: ubuntu-22.04
+    steps:
+      # If this is a closed PR the prerun step closes the branch deployment and returns
+      # output.result='skip' which is used to skip other steps in this workflow.
+      - name: Pre-run checks
+        id: prerun
+        uses: dagster-io/dagster-cloud-action/actions/utils/prerun@v0.1
+
+      # Checkout the project
+      - name: Checkout
+        uses: actions/checkout@v3
+        if: steps.prerun.outputs.result != 'skip'
+        with:
+          ref: ${{ github.head_ref }}
+
+      # Validate dagster_cloud.yaml and the connection to dagster.cloud
+      - name: Validate configuration
+        id: ci-validate
+        if: steps.prerun.outputs.result != 'skip'
+        uses: dagster-io/dagster-cloud-action/actions/utils/dagster-cloud-cli@v0.1
+        with:
+          command: "ci check --project-dir ${{ env.DAGSTER_PROJECT_DIR }} --dagster-cloud-yaml-path ${{ env.DAGSTER_CLOUD_YAML_PATH }}"
+
+      # Parse dagster_cloud.yaml, detect if this is branch deployment and initialize the build session
+      - name: Initialize build session
+        id: ci-init
+        if: steps.prerun.outputs.result != 'skip'
+        uses: dagster-io/dagster-cloud-action/actions/utils/ci-init@v0.1
+        with:
+          project_dir: ${{ env.DAGSTER_PROJECT_DIR }}
+          dagster_cloud_yaml_path: ${{ env.DAGSTER_CLOUD_YAML_PATH }}
+          # A full deployment name. If this run is for a pull request, this value will be used as
+          # the base deployment for the branch deployment.
+          deployment: "prod"
path: python_modules/libraries/dagster-dg/dagster_dg/templates/hybrid-github-action.yaml
user: login: benpankow
body: Good catch, updated
created_at: 2025-04-16T21:37:39Z
author_association: MEMBER
diff_hunk: @@ -277,23 +373,75 @@ def scaffold_github_actions_command(git_root: Optional[Path], **global_options:
     workflows_dir = git_root / ".github" / "workflows"
     workflows_dir.mkdir(parents=True, exist_ok=True)
 
-    template = SERVERLESS_GITHUB_ACTION_FILE.read_text()
-
     if plus_config and plus_config.organization:
         organization_name = plus_config.organization
         click.echo(f"Using organization name {organization_name} from Dagster Plus config.")
     else:
         organization_name = click.prompt("Dagster Plus organization name") or ""
 
-    template = template.replace("ORGANIZATION_NAME", organization_name)
+    if plus_config and plus_config.default_deployment:
+        deployment_name = plus_config.default_deployment
+        click.echo(f"Using default deployment name {deployment_name} from Dagster Plus config.")
+    else:
+        deployment_name = click.prompt("Default deployment name", default="prod")
+
+    agent_type = get_agent_type(plus_config)
+    if agent_type == "SERVERLESS":
+        click.echo("Using serverless workflow template.")
+    else:
+        click.echo("Using hybrid workflow template.")
+
+    registry_url = None
+    additional_secrets_hints = []
+
+    template = (
+        SERVERLESS_GITHUB_ACTION_FILE.read_text()
+        if agent_type == "SERVERLESS"
+        else HYBRID_GITHUB_ACTION_FILE.read_text()
+    )
+
+    template = template.replace("ORGANIZATION_NAME", organization_name).replace(
+        "DEFAULT_DEPLOYMENT_NAME", deployment_name
+    )
+
+    if agent_type == "HYBRID":
+        # Attempt to read registry from build.yaml
+        registry_url = dg_context.build_config["registry"] if dg_context.build_config else None
+        if not registry_url:
+            registry_url = click.prompt("Docker registry URL, for built images")
+        else:
+            click.echo(f"Using Docker registry URL {registry_url} from build.yaml")
+
+        project_contexts = _get_project_contexts(dg_context, cli_config)
+
+        for location_ctx in project_contexts:
+            create_deploy_dockerfile(
+                location_ctx.root_path / "Dockerfile",
+                "3.11",
+                use_editable_dagster=False,
+            )
+        build_fragment = _get_build_fragment_for_locations(project_contexts, git_root)
+
+        template = template.replace("# BUILD_LOCATION_FRAGMENT", build_fragment).replace(
+            "IMAGE_REGISTRY_TEMPLATE", registry_url
+        )
+
+    if registry_url:
+        for registry_info in REGISTRY_INFOS:
+            if registry_info.match(registry_url):
+                template = template.replace(
+                    "# CONTAINER_REGISTRY_LOGIN_FRAGMENT",
+                    textwrap.indent(registry_info.fragment.read_text(), " " * 6),
+                )
+                additional_secrets_hints.extend(registry_info.secrets_hints)
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: graphite-app[bot]
body: There's a bug in the Azure Container Registry login fragment processing. The fragment contains a placeholder `IMAGE_REGISTRY_TEMPLATE` that needs to be replaced with the actual registry URL, but the current implementation inserts the fragment text directly without replacing this placeholder.

This will cause authentication failures when using Azure Container Registry.

The fix is to replace the placeholder in the fragment content before inserting it into the template:

```python
if registry_info.match(registry_url):
    fragment_content = registry_info.fragment.read_text()
    fragment_content = fragment_content.replace("IMAGE_REGISTRY_TEMPLATE", registry_url)
    template = template.replace(
        "# CONTAINER_REGISTRY_LOGIN_FRAGMENT",
        textwrap.indent(fragment_content, " " * 6),
    )
```
```suggestion

        for registry_info in REGISTRY_INFOS:
            if registry_info.match(registry_url):
                fragment_content = registry_info.fragment.read_text()
                fragment_content = fragment_content.replace("IMAGE_REGISTRY_TEMPLATE", registry_url)
                template = template.replace(
                    "# CONTAINER_REGISTRY_LOGIN_FRAGMENT",
                    textwrap.indent(fragment_content, " " * 6),
                )
                additional_secrets_hints.extend(registry_info.secrets_hints)

```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-16T21:57:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -207,26 +212,109 @@ def _search_for_git_root(path: Path) -> Optional[Path]:
 SERVERLESS_GITHUB_ACTION_FILE = (
     Path(__file__).parent.parent / "templates" / "serverless-github-action.yaml"
 )
+HYBRID_GITHUB_ACTION_FILE = Path(__file__).parent.parent / "templates" / "hybrid-github-action.yaml"
 
 
-def _generate_dagster_cloud_yaml_contents(
-    dg_context: DgContext, git_root: Path, cli_config: DgRawCliConfig
-) -> dict:
-    project_contexts = (
-        [
+BUILD_LOCATION_FRAGMENT = (
+    Path(__file__).parent.parent / "templates" / "build-location-fragment.yaml"
+)
+
+
+class ContainerRegistryInfo(NamedTuple):
+    name: str
+    match: Callable[[str], bool]
+    fragment: Path
+    secrets_hints: list[str]
+
+
+REGISTRY_INFOS = [
+    ContainerRegistryInfo(
+        name="ECR",
+        match=lambda url: "ecr" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "ecr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AWS_ACCESS_KEY_ID --body "(your AWS access key ID)"',
+            'gh secret set AWS_SECRET_ACCESS_KEY --body "(your AWS secret access key)"',
+            'gh secret set AWS_REGION --body "(your AWS region)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="DockerHub",
+        match=lambda url: "docker.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "dockerhub-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set DOCKERHUB_USERNAME --body "(your DockerHub username)"',
+            'gh secret set DOCKERHUB_TOKEN --body "(your DockerHub token)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="GitHub Container Registry",
+        match=lambda url: "ghcr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "github-container-registry-login-fragment.yaml",
+        secrets_hints=[],
+    ),
+    ContainerRegistryInfo(
+        name="Azure Container Registry",
+        match=lambda url: "azurecr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "azure-container-registry-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AZURE_CLIENT_ID --body "(your Azure client ID)"',
+            'gh secret set AZURE_CLIENT_SECRET --body "(your Azure client secret)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="Google Container Registry",
+        match=lambda url: "gcr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "gcr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set GCR_JSON_KEY --body "(your GCR JSON key)"',
+        ],
+    ),
+]
+
+
+def _get_deployment_agent_type(gql_client: DagsterPlusGraphQLClient) -> str:
+    result = gql_client.execute(gql.DEPLOYMENT_INFO_QUERY)
+    return result["currentDeployment"]["agentType"]
+
+
+def _get_project_contexts(dg_context: DgContext, cli_config: DgRawCliConfig) -> list[DgContext]:
+    if dg_context.is_workspace:
+        return [
             dg_context.for_project_environment(project.path, cli_config)
             for project in dg_context.project_specs
         ]
-        if dg_context.is_workspace
-        else [dg_context]
-    )
+    else:
+        return [dg_context]
+
+
+def _generate_dagster_cloud_yaml_contents(
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: gibsondan
body: ok cool, the duplication with create_temp_dagster_cloud_yaml_file seems fairly egregious, but if its temporary that's ok
created_at: 2025-04-17T14:43:05Z
author_association: MEMBER
diff_hunk: @@ -248,6 +331,19 @@ def _get_git_web_url(git_root: Path) -> Optional[str]:
         return None
 
 
+def _get_build_fragment_for_locations(location_ctxs: list[DgContext], git_root: Path) -> str:
+    # TODO: when we cut over to dg deploy, we'll just use a single build call for the workspace
+    # rather than iterating over each project
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: gibsondan
body: its actually not 100% clear to me that this is the case? It probably depends on how they have their build.yaml configured. what exactly the default should be is tricky. Right now dg deploy actually does iterate through each project and build them one by one...
created_at: 2025-04-17T14:44:14Z
author_association: MEMBER
diff_hunk: @@ -277,23 +373,75 @@ def scaffold_github_actions_command(git_root: Optional[Path], **global_options:
     workflows_dir = git_root / ".github" / "workflows"
     workflows_dir.mkdir(parents=True, exist_ok=True)
 
-    template = SERVERLESS_GITHUB_ACTION_FILE.read_text()
-
     if plus_config and plus_config.organization:
         organization_name = plus_config.organization
         click.echo(f"Using organization name {organization_name} from Dagster Plus config.")
     else:
         organization_name = click.prompt("Dagster Plus organization name") or ""
 
-    template = template.replace("ORGANIZATION_NAME", organization_name)
+    if plus_config and plus_config.default_deployment:
+        deployment_name = plus_config.default_deployment
+        click.echo(f"Using default deployment name {deployment_name} from Dagster Plus config.")
+    else:
+        deployment_name = click.prompt("Default deployment name", default="prod")
+
+    agent_type = get_agent_type(plus_config)
+    if agent_type == "SERVERLESS":
+        click.echo("Using serverless workflow template.")
+    else:
+        click.echo("Using hybrid workflow template.")
+
+    registry_url = None
+    additional_secrets_hints = []
+
+    template = (
+        SERVERLESS_GITHUB_ACTION_FILE.read_text()
+        if agent_type == "SERVERLESS"
+        else HYBRID_GITHUB_ACTION_FILE.read_text()
+    )
+
+    template = template.replace("ORGANIZATION_NAME", organization_name).replace(
+        "DEFAULT_DEPLOYMENT_NAME", deployment_name
+    )
+
+    if agent_type == "HYBRID":
+        # Attempt to read registry from build.yaml
+        registry_url = dg_context.build_config["registry"] if dg_context.build_config else None
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: gibsondan
body: this should use merged_build_config in a workspace context, see https://github.com/dagster-io/dagster/blob/master/python_modules/libraries/dagster-dg/dagster_dg/cli/plus/deploy_session.py#L233-L248
created_at: 2025-04-17T14:47:38Z
author_association: MEMBER
diff_hunk: @@ -208,29 +212,112 @@ def _search_for_git_root(path: Path) -> Optional[Path]:
 SERVERLESS_GITHUB_ACTION_FILE = (
     Path(__file__).parent.parent / "templates" / "serverless-github-action.yaml"
 )
+HYBRID_GITHUB_ACTION_FILE = Path(__file__).parent.parent / "templates" / "hybrid-github-action.yaml"
 
 
-def _generate_dagster_cloud_yaml_contents(
-    dg_context: DgContext, git_root: Path, cli_config: DgRawCliConfig
-) -> dict:
-    project_contexts = (
-        [
+BUILD_LOCATION_FRAGMENT = (
+    Path(__file__).parent.parent / "templates" / "build-location-fragment.yaml"
+)
+
+
+class ContainerRegistryInfo(NamedTuple):
+    name: str
+    match: Callable[[str], bool]
+    fragment: Path
+    secrets_hints: list[str]
+
+
+REGISTRY_INFOS = [
+    ContainerRegistryInfo(
+        name="ECR",
+        match=lambda url: "ecr" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "ecr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AWS_ACCESS_KEY_ID --body "(your AWS access key ID)"',
+            'gh secret set AWS_SECRET_ACCESS_KEY --body "(your AWS secret access key)"',
+            'gh secret set AWS_REGION --body "(your AWS region)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="DockerHub",
+        match=lambda url: "docker.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "dockerhub-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set DOCKERHUB_USERNAME --body "(your DockerHub username)"',
+            'gh secret set DOCKERHUB_TOKEN --body "(your DockerHub token)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="GitHub Container Registry",
+        match=lambda url: "ghcr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "github-container-registry-login-fragment.yaml",
+        secrets_hints=[],
+    ),
+    ContainerRegistryInfo(
+        name="Azure Container Registry",
+        match=lambda url: "azurecr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "azure-container-registry-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set AZURE_CLIENT_ID --body "(your Azure client ID)"',
+            'gh secret set AZURE_CLIENT_SECRET --body "(your Azure client secret)"',
+        ],
+    ),
+    ContainerRegistryInfo(
+        name="Google Container Registry",
+        match=lambda url: "gcr.io" in url,
+        fragment=Path(__file__).parent.parent
+        / "templates"
+        / "registry_fragments"
+        / "gcr-login-fragment.yaml",
+        secrets_hints=[
+            'gh secret set GCR_JSON_KEY --body "(your GCR JSON key)"',
+        ],
+    ),
+]
+
+
+def _get_project_contexts(dg_context: DgContext, cli_config: DgRawCliConfig) -> list[DgContext]:
+    if dg_context.is_workspace:
+        return [
             dg_context.for_project_environment(project.path, cli_config)
             for project in dg_context.project_specs
         ]
-        if dg_context.is_workspace
-        else [dg_context]
-    )
+    else:
+        return [dg_context]
+
+
+def _generate_dagster_cloud_yaml_contents(
+    dg_context: DgContext,
+    git_root: Path,
+    cli_config: DgRawCliConfig,
+    registry_urls: Optional[list[str]],
+) -> dict:
+    project_contexts = _get_project_contexts(dg_context, cli_config)
     return {
         "locations": [
             {
                 "location_name": project_context.code_location_name,
                 "code_source": {"module_name": project_context.code_location_target_module_name},
                 "build": {
                     "directory": str(project_context.root_path.relative_to(git_root)),
+                    **({"registry": registry_url} if registry_url else {}),
                 },
             }
-            for project_context in project_contexts
+            for project_context, registry_url in zip(
+                project_contexts, registry_urls or itertools.repeat(None)
+            )
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: graphite-app[bot]
body: The `zip` function will stop at the shortest iterable, so if `registry_urls` is an empty list but `project_contexts` has items, no locations will be included in the output. Consider using `registry_urls or [None] * len(project_contexts)` to ensure all projects are processed even when `registry_urls` is empty.
```suggestion
            for project_context, registry_url in zip(
                project_contexts, registry_urls or [None] * len(project_contexts)
            )
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-17T16:15:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -277,23 +380,77 @@ def scaffold_github_actions_command(git_root: Optional[Path], **global_options:
     workflows_dir = git_root / ".github" / "workflows"
     workflows_dir.mkdir(parents=True, exist_ok=True)
 
-    template = SERVERLESS_GITHUB_ACTION_FILE.read_text()
-
     if plus_config and plus_config.organization:
         organization_name = plus_config.organization
         click.echo(f"Using organization name {organization_name} from Dagster Plus config.")
     else:
         organization_name = click.prompt("Dagster Plus organization name") or ""
 
-    template = template.replace("ORGANIZATION_NAME", organization_name)
+    if plus_config and plus_config.default_deployment:
+        deployment_name = plus_config.default_deployment
+        click.echo(f"Using default deployment name {deployment_name} from Dagster Plus config.")
+    else:
+        deployment_name = click.prompt("Default deployment name", default="prod")
+
+    agent_type = get_agent_type(plus_config)
+    if agent_type == "SERVERLESS":
+        click.echo("Using serverless workflow template.")
+    else:
+        click.echo("Using hybrid workflow template.")
+
+    additional_secrets_hints = []
+
+    template = (
+        SERVERLESS_GITHUB_ACTION_FILE.read_text()
+        if agent_type == "SERVERLESS"
+        else HYBRID_GITHUB_ACTION_FILE.read_text()
+    )
+
+    template = template.replace("ORGANIZATION_NAME", organization_name).replace(
+        "DEFAULT_DEPLOYMENT_NAME", deployment_name
+    )
+
+    registry_urls = None
+    if agent_type == "HYBRID":
+        project_contexts = _get_project_contexts(dg_context, cli_config)
+
+        registry_urls = [
+            merge_build_configs(project.build_config, dg_context.build_config).get("registry")
+            for project in project_contexts
+        ]
+        for project, registry_url in zip(project_contexts, registry_urls):
+            if registry_url is None:
+                raise click.ClickException(
+                    f"No registry URL found for project {project.code_location_name}. Please specify a registry URL in `build.yaml`."
+                )
+        registry_urls = cast("list[str]", registry_urls)
+
+        for location_ctx in project_contexts:
+            create_deploy_dockerfile(
+                location_ctx.root_path / "Dockerfile",
+                "3.11",
+                use_editable_dagster=False,
+            )
+        build_fragment = _get_build_fragment_for_locations(
+            project_contexts, git_root, registry_urls
+        )
+        template = template.replace("# BUILD_LOCATION_FRAGMENT", build_fragment)
+
+        for registry_info in REGISTRY_INFOS:
+            if any(registry_info.match(registry_url) for registry_url in registry_urls):
+                template = template.replace(
+                    "# CONTAINER_REGISTRY_LOGIN_FRAGMENT",
+                    textwrap.indent(registry_info.fragment.read_text(), " " * 6),
+                )
path: python_modules/libraries/dagster-dg/dagster_dg/cli/scaffold.py
user: login: graphite-app[bot]
body: The current implementation only supports a single registry type, even when projects use multiple registries. When iterating through `REGISTRY_INFOS`, each call to `template.replace("# CONTAINER_REGISTRY_LOGIN_FRAGMENT", ...)` overwrites any previous replacements.

If projects use multiple registry types (e.g., ECR and DockerHub), only the login fragment for the last matched registry will be included in the workflow file, causing authentication failures for other registries.

Consider concatenating all matching registry login fragments instead of replacing them:

```python
login_fragments = []
for registry_info in REGISTRY_INFOS:
    if any(registry_info.match(registry_url) for registry_url in registry_urls):
        login_fragments.append(textwrap.indent(registry_info.fragment.read_text(), " " * 6))

template = template.replace(
    "# CONTAINER_REGISTRY_LOGIN_FRAGMENT",
    "\n".join(login_fragments)
)
```
```suggestion

        login_fragments = []
        for registry_info in REGISTRY_INFOS:
            if any(registry_info.match(registry_url) for registry_url in registry_urls):
                login_fragments.append(textwrap.indent(registry_info.fragment.read_text(), " " * 6))

        template = template.replace(
            "# CONTAINER_REGISTRY_LOGIN_FRAGMENT",
            "\n".join(login_fragments)
        )

```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=dagster-io&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-04-17T16:39:58Z
author_association: CONTRIBUTOR

PR comments:
user: login: benpankow
created_at: 2025-04-07T16:46:02Z
body: * **#29335** <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/29335?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#29065** <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/29065?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/29065?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#28962** <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/28962?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `master`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: MEMBER
user: login: gibsondan
created_at: 2025-04-15T15:21:47Z
body: one other question is whether this works out of the box with both a project and a workspace? This could be something that we fix with the move to dg commands instead of dagster-cloud commands if not
author_association: MEMBER
user: login: benpankow
created_at: 2025-04-16T21:41:11Z
body: > one other question is whether this works out of the box with both a project and a workspace? This could be something that we fix with the move to dg commands instead of dagster-cloud commands if not

This does work in workspace scope, but sets a build command for each project. I added a todo to factor this out to a single build command once we move over to using dg
author_association: MEMBER