Build the container image once, test it, save it to Depot Registry, and promote the same immutable digest through staging and production.
Don't rebuild from the same Git commit for each environment. A second build can resolve a different base image, package version, timestamp, or network dependency. The source revision may be identical while the image bytes aren't.
Depot builds and stores the artifact. Kubernetes, Cloud Run, or another deployment system deploys it. Keep deployment approval, rollout policy, health checks, and rollback in the system that owns the runtime.
Build and save one immutable image
Depot CI automatically injects a short-lived DEPOT_TOKEN, so depot/build-push-action can save the result to Depot
Registry without a registry login:
name: Build image
on:
push:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: depot-ubuntu-24.04
outputs:
build-id: ${{ steps.image.outputs.build-id }}
digest: ${{ steps.image.outputs.digest }}
image: registry.depot.dev/<project-id>@${{ steps.image.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: depot/setup-action@v1
- name: Build and save image
id: image
uses: depot/build-push-action@v1
with:
project: <project-id>
context: .
platforms: linux/amd64,linux/arm64
save: true
save-tags: |
${{ github.sha }}
smoke-test:
needs: build
runs-on: depot-ubuntu-24.04
steps:
- name: Authenticate to Depot Registry
run: printf '%s' "$DEPOT_TOKEN" | docker login registry.depot.dev --username x-token --password-stdin
- name: Run the saved image
env:
IMAGE: ${{ needs.build.outputs.image }}
run: |
docker pull "$IMAGE"
docker run --detach --name api --publish 8080:8080 "$IMAGE"
for attempt in $(seq 1 30); do
if curl --fail --silent http://127.0.0.1:8080/healthz; then
exit 0
fi
sleep 1
done
docker logs api
exit 1
record-release:
needs: [build, smoke-test]
runs-on: depot-ubuntu-24.04
steps:
- name: Record promotable artifact
env:
BUILD_ID: ${{ needs.build.outputs.build-id }}
DIGEST: ${{ needs.build.outputs.digest }}
IMAGE: ${{ needs.build.outputs.image }}
run: |
{
echo '## Promotable image'
echo
echo "- Build ID: \`$BUILD_ID\`"
echo "- Digest: \`$DIGEST\`"
echo "- Image: \`$IMAGE\`"
} >> "$GITHUB_STEP_SUMMARY"depot/build-push-action exposes the same outputs as docker/build-push-action, including the image digest. Record
the full registry reference with @sha256:... in the release record.
The commit tag is useful for humans. The digest is the deployment identity. Tags can move. A digest identifies the exact manifest produced by the build.
Test the artifact that will be deployed
The example expects the application to listen on port 8080 and return a successful response from /healthz. Adapt the
health check to the application, but keep the image input as needs.build.outputs.image. The test pulls the saved
digest, not a mutable tag.
record-release runs only after smoke-test passes. Use the build ID and image digest from that record as the inputs
to manual promotion. Don't promote values copied from the build job when its smoke test failed or never ran.
This smoke-test job runs on an AMD64 sandbox, so it exercises only the AMD64 image selected from the multi-platform
manifest. Add a second depot-ubuntu-24.04-arm smoke-test job when production also runs the ARM64 image.
Don't compile the application again in the deployment job. That tests one artifact and deploys another.
For multi-platform images, the digest points to the manifest list that contains the AMD64 and ARM64 images. Depot runs those builds on native CPUs in parallel and keeps the project layer cache across local and CI builds.
Promote by digest
Pass the immutable image reference into a separately dispatched deployment workflow:
name: Deploy image
on:
workflow_dispatch:
inputs:
image:
description: Immutable image reference including sha256 digest
required: true
type: string
environment:
description: Deployment environment
required: true
type: choice
options:
- staging
- production
jobs:
deploy:
runs-on: depot-ubuntu-24.04
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- name: Validate immutable source image
env:
INPUT_IMAGE: ${{ inputs.image }}
run: |
expected_prefix='registry.depot.dev/<project-id>@'
case "$INPUT_IMAGE" in
"$expected_prefix"*) ;;
*) echo "Image must use $expected_prefix" >&2; exit 1 ;;
esac
digest="${INPUT_IMAGE#*@}"
printf '%s' "$digest" | grep --extended-regexp --quiet '^sha256:[0-9a-f]{64}$'
- name: Configure Kubernetes credentials
run: |
printf '%s' "$KUBECONFIG_B64" | base64 --decode > "$RUNNER_TEMP/kubeconfig"
echo "KUBECONFIG=$RUNNER_TEMP/kubeconfig" >> "$GITHUB_ENV"
env:
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
- name: Deploy exact image
env:
IMAGE: ${{ inputs.image }}
run: |
kubectl set image deployment/api api="$IMAGE"
kubectl rollout status deployment/api --timeout=5m
deployed_image="$(kubectl get deployment/api -o jsonpath='{.spec.template.spec.containers[?(@.name=="api")].image}')"
test "$deployed_image" = "$IMAGE"Create KUBECONFIG_B64 variants limited to the staging and production environments. The workflow keeps one secret
name while Depot selects the matching cluster credential.
The cluster must already contain a Deployment whose container and pull-secret names match the workflow. This minimal manifest defines those pieces:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 2
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
imagePullSecrets:
- name: depot-registry
containers:
- name: api
image: registry.depot.dev/<project-id>@sha256:<initial-digest>
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5kubectl set image deployment/api api=... updates the container named api. The rollout command waits for the
Deployment controller to report the new ReplicaSet ready, then the final command verifies the image on that named
container rather than assuming it's the first container in the pod.
The manual dispatch is the release decision in this example. The environment field selects secret variants. It
doesn't add a human approval by itself. If production requires reviewers, policy checks, progressive delivery, or a
change window, enforce those in the deployment system or in the process that dispatches this workflow.
Copy the verified image before deploying to Cloud Run
Cloud Run doesn't pull an arbitrary private Depot Registry image directly. Copy the saved build to Google Artifact
Registry, verify the destination digest, then deploy the Artifact Registry reference. Use the values from a successful
record-release job. This separately dispatched Depot CI workflow declares the build, digest, and environment inputs:
on:
workflow_dispatch:
inputs:
build_id:
description: Depot build ID from the promotable release record
required: true
type: string
source_digest:
description: sha256 digest from the promotable release record
required: true
type: string
environment:
description: Deployment environment
required: true
type: choice
options:
- staging
- production
permissions:
contents: read
id-token: write
jobs:
deploy-cloud-run:
runs-on: depot-ubuntu-24.04
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v3
with:
project_id: ${{ vars.GCP_PROJECT_ID }}
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v3
- uses: depot/setup-action@v1
- name: Copy, verify, and deploy
env:
BUILD_ID: ${{ inputs.build_id }}
SOURCE_DIGEST: ${{ inputs.source_digest }}
GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
GOOGLE_CLOUD_REGION: ${{ vars.GOOGLE_CLOUD_REGION }}
ARTIFACT_REPOSITORY: ${{ vars.ARTIFACT_REPOSITORY }}
run: |
printf '%s' "$SOURCE_DIGEST" | grep --extended-regexp --quiet '^sha256:[0-9a-f]{64}$'
registry_host="$GOOGLE_CLOUD_REGION-docker.pkg.dev"
destination_image="$registry_host/$GCP_PROJECT_ID/$ARTIFACT_REPOSITORY/api"
destination_tag="promotion-$GITHUB_RUN_ID"
gcloud auth configure-docker "$registry_host" --quiet
depot push \
--project <project-id> \
--tag "$destination_image:$destination_tag" \
"$BUILD_ID"
destination_digest="$(
gcloud artifacts docker images describe \
"$destination_image:$destination_tag" \
--format='value(image_summary.digest)'
)"
test "$destination_digest" = "$SOURCE_DIGEST"
destination_ref="$destination_image@$destination_digest"
gcloud run deploy api \
--image "$destination_ref" \
--region "$GOOGLE_CLOUD_REGION" \
--quiet
deployed_image="$(
gcloud run services describe api \
--region "$GOOGLE_CLOUD_REGION" \
--format='value(spec.template.spec.containers[0].image)'
)"
test "$deployed_image" = "$destination_ref"Configure Google Cloud Workload Identity Federation to trust Depot CI's https://identity.depot.dev issuer and scope
the trust to the intended Depot organization, repository, and branch. A provider configured only for GitHub's
https://token.actions.githubusercontent.com issuer will reject Depot CI tokens. The
Depot CI OIDC guide documents the issuer, subject format, claims, and migration path.
The Google service account needs permission to write the Artifact Registry repository and deploy the Cloud Run service. The workflow identity also needs permission to impersonate that service account, and the deployer needs permission to act as the Cloud Run runtime service account. Create the Artifact Registry Docker repository before the workflow runs. Cloud Run deploys container images from supported registries, and Artifact Registry exposes image digests for this verification step.
The transfer preserves the OCI manifest digest. Refuse the deployment if the destination digest differs from the source digest tested in CI.
Make the image pullable by the runtime
Depot Registry is private. A Kubernetes cluster needs an image pull secret or another supported Depot token available to the runtime:
kubectl create secret docker-registry depot-registry \
--docker-server=registry.depot.dev \
--docker-username=x-token \
--docker-password=<depot-token>Reference that secret through imagePullSecrets in the workload. Use a token with only the access the runtime needs.
You can also use depot push to copy a saved build directly
from Depot infrastructure into ECR, Artifact Registry, Docker Hub, or another registry. The local runner doesn't need
to download and upload every layer.
Keep the promoted digest long enough
Retention is part of release correctness. A rollback fails if the previous digest has already been deleted.
Tagged ordinary Depot Registry repositories have opt-in retention rules. Untagged manifests default to three days when
the repository uses its default rule. Images saved from depot build --save use the project's image retention policy,
which defaults to Unlimited and can be set to 1, 7, 14, or 30 days.
Choose retention from the rollback window, not only the frequency of new builds. Keep every production digest and its immediate rollback candidates for as long as the deployment policy requires.
Roll back by changing the pointer
A rollback should deploy the previous known-good digest. It shouldn't rerun the old build.
Record the commit SHA, Depot build ID, digest, test run, and deployment environment together. That gives the release system enough information to answer what is running and to restore it without trying to recreate old bytes from old source.