diff --git a/MODULE.bazel b/MODULE.bazel
index a4d344b7..95019230 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -12,6 +12,7 @@ bazel_dep(name = "rules_img", version = "0.3.13")
bazel_dep(name = "rules_pkg", version = "1.0.1")
bazel_dep(name = "rules_go", version = "0.54.0")
bazel_dep(name = "rules_shell", version = "0.8.0")
+bazel_dep(name = "yq.bzl", version = "0.3.6")
go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.23.9")
diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock
index 7f0b0e4e..ae2814fc 100644
--- a/MODULE.bazel.lock
+++ b/MODULE.bazel.lock
@@ -28,6 +28,8 @@
"https://bcr.bazel.build/modules/bazel_features/1.47.0/source.json": "4ba0b5138327f2d73352a51547a4e49a0a828ef400e046b15334d8905bf6b7ff",
"https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
+ "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d",
+ "https://bcr.bazel.build/modules/bazel_lib/3.0.0/source.json": "895f21909c6fba01d7c17914bb6c8e135982275a1b18cdaa4e62272217ef1751",
"https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8",
"https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e",
"https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686",
@@ -39,6 +41,7 @@
"https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
+ "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7",
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0",
"https://bcr.bazel.build/modules/buildifier_prebuilt/8.0.3/MODULE.bazel": "4791b031727d1183c183a0b8fd5fc0fe8d8834c45f9a22efd26be6c962b9cfee",
@@ -63,6 +66,7 @@
"https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902",
"https://bcr.bazel.build/modules/package_metadata/0.0.10/MODULE.bazel": "cb66e0ce830e01bc00cd9edc984963704f2759ca893a649996e2b1c5c1ea7271",
"https://bcr.bazel.build/modules/package_metadata/0.0.10/source.json": "8fc6bece244828a69b3cc608f381118d67fa3cc2aa1a2851dfe8d99a8076d7d0",
+ "https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92",
"https://bcr.bazel.build/modules/package_metadata/0.0.3/MODULE.bazel": "77890552ecea9e284b5424c9de827a58099348763a4359e975c359a83d4faa83",
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
"https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f",
@@ -162,6 +166,7 @@
"https://bcr.bazel.build/modules/rules_runfiles_group/0.0.1-rc.5/source.json": "32efc01a4b29cbb5b2b97fd1f7d6025e68eb20eea498f1127b068082e315d6cf",
"https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
+ "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592",
"https://bcr.bazel.build/modules/rules_shell/0.8.0/MODULE.bazel": "f6a89f1d6a669a26f28fe814503857055d76306b79cfc11d12399af08d0b80ae",
"https://bcr.bazel.build/modules/rules_shell/0.8.0/source.json": "eb53cc815bc503c6683c5fe12d943f98883f81fc22f51403ec8a95610cba4195",
"https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8",
@@ -173,6 +178,8 @@
"https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7",
"https://bcr.bazel.build/modules/stardoc/0.7.1/source.json": "b6500ffcd7b48cd72c29bb67bcac781e12701cc0d6d55d266a652583cfcdab01",
"https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43",
+ "https://bcr.bazel.build/modules/yq.bzl/0.3.6/MODULE.bazel": "985c2a0cb4ad9994bb0e33cc7fae931c91105eeefe3faa355b8f4c258d0607c0",
+ "https://bcr.bazel.build/modules/yq.bzl/0.3.6/source.json": "678aaf6e291164f3cd761bb3e872e8a151248f413dbb63c5524a50b82a5bc890",
"https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0",
"https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27",
"https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca",
@@ -3223,6 +3230,87 @@
]
]
}
+ },
+ "@@yq.bzl+//yq:extensions.bzl%yq": {
+ "general": {
+ "bzlTransitiveDigest": "UfFMy8CWK4/dVo/tfaSAIYUiDGNAPes5eRllx9O9Q9Q=",
+ "usagesDigest": "HIivRx64D04dq6NJIBX8x2pgz4Az6/Qw39gO1ihBaBU=",
+ "recordedFileInputs": {},
+ "recordedDirentsInputs": {},
+ "envVariables": {},
+ "generatedRepoSpecs": {
+ "yq_darwin_amd64": {
+ "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+ "attributes": {
+ "platform": "darwin_amd64",
+ "version": "4.45.2"
+ }
+ },
+ "yq_darwin_arm64": {
+ "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+ "attributes": {
+ "platform": "darwin_arm64",
+ "version": "4.45.2"
+ }
+ },
+ "yq_linux_amd64": {
+ "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+ "attributes": {
+ "platform": "linux_amd64",
+ "version": "4.45.2"
+ }
+ },
+ "yq_linux_arm64": {
+ "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+ "attributes": {
+ "platform": "linux_arm64",
+ "version": "4.45.2"
+ }
+ },
+ "yq_linux_s390x": {
+ "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+ "attributes": {
+ "platform": "linux_s390x",
+ "version": "4.45.2"
+ }
+ },
+ "yq_linux_riscv64": {
+ "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+ "attributes": {
+ "platform": "linux_riscv64",
+ "version": "4.45.2"
+ }
+ },
+ "yq_linux_ppc64le": {
+ "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+ "attributes": {
+ "platform": "linux_ppc64le",
+ "version": "4.45.2"
+ }
+ },
+ "yq_windows_amd64": {
+ "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+ "attributes": {
+ "platform": "windows_amd64",
+ "version": "4.45.2"
+ }
+ },
+ "yq_windows_arm64": {
+ "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
+ "attributes": {
+ "platform": "windows_arm64",
+ "version": "4.45.2"
+ }
+ },
+ "yq_toolchains": {
+ "repoRuleId": "@@yq.bzl+//yq/toolchain:toolchain.bzl%yq_toolchains_repo",
+ "attributes": {
+ "user_repository_name": "yq"
+ }
+ }
+ },
+ "recordedRepoMappingEntries": []
+ }
}
},
"facts": {}
diff --git a/README.md b/README.md
index c93e1356..3392ebd6 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ Bazel GitOps Rules is an alternative to [rules_k8s](https://github.com/bazelbuil
## Rules
* [k8s_deploy](#k8s_deploy)
+* [cloudrun_deploy](#cloudrun_deploy)
* [k8s_test_setup](#k8s_test_setup)
@@ -96,6 +97,23 @@ When you run `bazel run ///helloworld:mynamespace.apply`, it applies this file i
| ***visibility*** | [Default_visibility](https://docs.bazel.build/versions/master/be/functions.html#package.default_visibility) | Changes the visibility of all rules generated by this macro. See [Bazel docs on visibility](https://docs.bazel.build/versions/master/be/common-definitions.html#common-attributes).
+
+## cloudrun_deploy
+
+The `cloudrun_deploy` macro creates targets for deploying Knative Services to Google Cloud Run. It is defined in [cloudrun.bzl](./gitops/cloudrun.bzl) and is loaded via `load("@rules_gitops//gitops:defs.bzl", "cloudrun_deploy")`.
+
+Similar to `k8s_deploy`, it uses kustomize to render the Knative Service manifest.
+When you run a `.apply` target, it deploys the service by running `gcloud run services replace`.
+When you run a `.delete` target, it deletes the service by running `gcloud run services delete`.
+When you run a `.gitops` target, it copies the rendered manifest to the location `cloud/{project}/{region}/{name}.yaml` inside the deployment repository.
+
+It accepts similar parameters to `k8s_deploy`, with the following differences:
+- ***project*** (instead of `namespace`): The GCP project ID. This is also automatically set as the `namespace` field in the Knative Service YAML.
+- ***region*** (instead of `cluster`): The GCP region (e.g. `us-central1`).
+
+Note: There is no `service` parameter. The service name is dynamically resolved at runtime from `metadata.name` inside the rendered manifest.
+
+
### Base Manifests and Overlays
diff --git a/gitops/BUILD.bazel b/gitops/BUILD.bazel
index 43a7eec5..65d86d21 100644
--- a/gitops/BUILD.bazel
+++ b/gitops/BUILD.bazel
@@ -12,7 +12,10 @@ load("//gitops/private:kustomize_toolchain.bzl", "current_kustomize_toolchain")
package(default_visibility = ["//visibility:public"])
-exports_files(["create_gitops_prs.tpl.sh"])
+exports_files([
+ "create_gitops_prs.tpl.sh",
+ "cloudrun.sh.tpl",
+])
toolchain_type(
name = "kustomize_toolchain_type",
diff --git a/gitops/cloudrun.bzl b/gitops/cloudrun.bzl
new file mode 100644
index 00000000..c0320ddc
--- /dev/null
+++ b/gitops/cloudrun.bzl
@@ -0,0 +1,510 @@
+# Copyright 2026 The rules_gitops Authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+load("//gitops:provider.bzl", "GitopsArtifactsInfo", "GitopsPushInfo")
+load("//push_oci:push_oci.bzl", "push_oci")
+load("//skylib:push_alias.bzl", "pushed_image_alias")
+load("//skylib:runfile.bzl", "get_runfile_path")
+load("//skylib:stamp.bzl", "stamp")
+load(
+ "//skylib/kustomize:kustomize.bzl",
+ "imagePushStatements",
+ "kustomize",
+)
+
+def _image_pushes(name_suffix, images, image_registry, image_repository, image_digest_tag, tags = []):
+ image_pushes = []
+
+ def process_image(image_label, image_alias = None):
+ rule_name_parts = [image_label, image_registry, image_repository]
+ rule_name_parts = [p for p in rule_name_parts if p]
+ rule_name = "_".join(rule_name_parts)
+ rule_name = rule_name.replace("/", "_").replace(":", "_").replace("@", "_").replace(".", "_")
+ rule_name = rule_name.strip("_")
+ if not native.existing_rule(rule_name + name_suffix):
+ push_oci(
+ name = rule_name + name_suffix,
+ image = image_label, # buildifier: disable=uninitialized
+ image_digest_tag = image_digest_tag,
+ registry = image_registry,
+ repository = image_repository,
+ tags = tags,
+ visibility = ["//visibility:public"],
+ )
+ if not image_alias:
+ return rule_name + name_suffix
+
+ if not native.existing_rule(rule_name + "_alias_" + name_suffix):
+ pushed_image_alias(
+ name = rule_name + "_alias_" + name_suffix,
+ alias = image_alias,
+ pushed_image = rule_name + name_suffix,
+ tags = tags,
+ visibility = ["//visibility:public"],
+ )
+ return rule_name + "_alias_" + name_suffix
+
+ if type(images) == "dict":
+ for image_alias in images:
+ image = images[image_alias]
+ push = process_image(image, image_alias)
+ image_pushes.append(push)
+ else:
+ for image in images:
+ push = process_image(image)
+ image_pushes.append(push)
+ return image_pushes
+
+def _gcloud_run_impl(ctx):
+ files = [] + ctx.files.srcs
+ yq_bin = ctx.toolchains["@yq.bzl//yq/toolchain:type"].yqinfo.bin
+ files.append(yq_bin)
+ statements = ""
+ transitive = None
+ transitive_runfiles = []
+
+ project = ctx.attr.project
+ if "{" in ctx.attr.project:
+ project = stamp(ctx, project, files, ctx.label.name + ".project-name", True)
+
+ region = ctx.attr.region
+ if "{" in ctx.attr.region:
+ region = stamp(ctx, region, files, ctx.label.name + ".region-name", True)
+
+ command = ctx.attr.command
+ push = ctx.attr.push
+
+ files += [ctx.executable._template_engine, ctx.file._info_file]
+
+ if push:
+ pushes = [obj[GitopsArtifactsInfo].image_pushes for obj in ctx.attr.srcs]
+ trans_img_pushes = depset(transitive = pushes).to_list()
+ trans_img_pushes = [push for push in trans_img_pushes if push.files_to_run.executable]
+ statements += "\n".join([
+ "# {}\n".format(exe[GitopsPushInfo].image_label) +
+ "echo pushing {}".format(exe[GitopsPushInfo].repository if GitopsPushInfo in exe else "")
+ for exe in trans_img_pushes
+ ]) + "\n"
+ statements += "\n".join([
+ "async \"%s\"" % get_runfile_path(ctx, exe.files_to_run.executable)
+ for exe in trans_img_pushes
+ ]) + "\nwaitpids\n"
+ files += [obj.files_to_run.executable for obj in trans_img_pushes]
+ transitive = depset(transitive = [obj.default_runfiles.files for obj in trans_img_pushes])
+ transitive_runfiles += [exe[DefaultInfo].default_runfiles for exe in trans_img_pushes]
+
+ for inattr in ctx.attr.srcs:
+ for infile in inattr.files.to_list():
+ if command == "replace":
+ statements += "{template_engine} --template={infile} --variable=PROJECT=\"$PROJECT\" --variable=REGION=\"$REGION\" --stamp_info_file={info_file} | gcloud run services replace - --project=\"$PROJECT\" --region=\"$REGION\"\n".format(
+ infile = get_runfile_path(ctx, infile),
+ template_engine = get_runfile_path(ctx, ctx.executable._template_engine),
+ info_file = get_runfile_path(ctx, ctx.file._info_file),
+ )
+ elif command == "delete":
+ statements += ("SERVICES=$({yq_bin_path} 'select(.kind == \"Service\") | .metadata.name' {infile})\n" +
+ "if [ -z \"$SERVICES\" ] || [ \"$SERVICES\" = \"null\" ]; then\n" +
+ " echo \"Error: Failed to extract any service names from manifest {infile}\" >&2\n" +
+ " exit 1\n" +
+ "fi\n" +
+ "for SERVICE in $SERVICES; do\n" +
+ " gcloud run services delete \"$SERVICE\" --project=\"$PROJECT\" --region=\"$REGION\"\n" +
+ "done\n").format(
+ yq_bin_path = get_runfile_path(ctx, yq_bin),
+ infile = get_runfile_path(ctx, infile),
+ )
+ else:
+ fail("Unsupported command: %s" % command)
+
+ ctx.actions.expand_template(
+ template = ctx.file._template,
+ substitutions = {
+ "%{project}": project,
+ "%{region}": region,
+ "%{statements}": statements,
+ },
+ output = ctx.outputs.executable,
+ )
+
+ runfiles = ctx.runfiles(files = files, transitive_files = transitive)
+ runfiles = runfiles.merge_all(transitive_runfiles)
+
+ return [
+ DefaultInfo(runfiles = runfiles),
+ ]
+
+gcloud_run = rule(
+ attrs = {
+ "srcs": attr.label_list(providers = (GitopsArtifactsInfo,)),
+ "project": attr.string(mandatory = True),
+ "region": attr.string(mandatory = True),
+ "command": attr.string(default = "replace"),
+ "push": attr.bool(default = True),
+ "_build_user_value": attr.label(
+ default = Label("//skylib:build_user_value.txt"),
+ allow_single_file = True,
+ ),
+ "_info_file": attr.label(
+ default = Label("//skylib:more_stable_status.txt"),
+ allow_single_file = True,
+ ),
+ "_stamper": attr.label(
+ default = Label("//stamper:stamper"),
+ cfg = "exec",
+ executable = True,
+ allow_files = True,
+ ),
+ "_template": attr.label(
+ default = Label("//gitops:cloudrun.sh.tpl"),
+ allow_single_file = True,
+ ),
+ "_template_engine": attr.label(
+ default = Label("//templating:fast_template_engine"),
+ executable = True,
+ cfg = "exec",
+ ),
+ },
+ executable = True,
+ implementation = _gcloud_run_impl,
+ toolchains = ["@yq.bzl//yq/toolchain:type"],
+)
+
+def _remove_prefix(s, prefix):
+ return s[len(prefix):] if s.startswith(prefix) else s
+
+def _remove_prefixes(s, prefixes):
+ for prefix in prefixes:
+ s = _remove_prefix(s, prefix)
+ return s
+
+def _cloudrun_gitops_impl(ctx):
+ region = ctx.attr.region
+ project = ctx.attr.project
+ strip_prefixes = ctx.attr.strip_prefixes
+ files = []
+
+ push_statements, files, pushes_runfiles = imagePushStatements(ctx, ctx.attr.srcs, files)
+ statements = """if [ "$PERFORM_PUSH" == "1" ]; then
+{}
+fi
+ """.format(push_statements)
+
+ if "{" in region:
+ fail("unable to gitops region with placeholders %s" % region)
+ if "{" in project:
+ fail("unable to gitops project with placeholders %s" % project)
+
+ for inattr in ctx.attr.srcs:
+ for infile in inattr.files.to_list():
+ statements += ("echo $TARGET_DIR/{gitops_path}/{project}/{region}/{file}\n" +
+ "mkdir -p $TARGET_DIR/{gitops_path}/{project}/{region}\n" +
+ "echo '# GENERATED BY {rulename} -> {gitopsrulename}' > $TARGET_DIR/{gitops_path}/{project}/{region}/{file}\n" +
+ "{template_engine} --template={infile} --variable=PROJECT={project} --variable=REGION={region} --stamp_info_file={info_file} >> $TARGET_DIR/{gitops_path}/{project}/{region}/{file}\n").format(
+ infile = get_runfile_path(ctx, infile),
+ rulename = inattr.label,
+ gitopsrulename = ctx.label,
+ project = project,
+ gitops_path = ctx.attr.gitops_path,
+ region = region,
+ file = _remove_prefixes(infile.path.split("/")[-1], strip_prefixes),
+ template_engine = get_runfile_path(ctx, ctx.executable._template_engine),
+ info_file = get_runfile_path(ctx, ctx.file._info_file),
+ )
+
+ ctx.actions.expand_template(
+ template = ctx.file._template,
+ substitutions = {
+ "%{deployment_branch}": ctx.attr.deployment_branch,
+ "%{statements}": statements,
+ },
+ output = ctx.outputs.executable,
+ )
+ runfiles = files + ctx.files.srcs + [ctx.executable._template_engine, ctx.file._info_file]
+ transitive = depset(transitive = [obj.default_runfiles.files for obj in ctx.attr.srcs])
+
+ rf = ctx.runfiles(files = runfiles, transitive_files = transitive)
+ for dep_rf in pushes_runfiles:
+ rf = rf.merge(dep_rf)
+ return [
+ DefaultInfo(runfiles = rf),
+ GitopsArtifactsInfo(
+ image_pushes = depset(transitive = [obj[GitopsArtifactsInfo].image_pushes for obj in ctx.attr.srcs]),
+ deployment_branch = ctx.attr.deployment_branch,
+ ),
+ ]
+
+cloudrun_gitops = rule(
+ attrs = {
+ "srcs": attr.label_list(providers = (GitopsArtifactsInfo,)),
+ "project": attr.string(mandatory = True),
+ "region": attr.string(mandatory = True),
+ "deployment_branch": attr.string(),
+ "gitops_path": attr.string(),
+ "release_branch_prefix": attr.string(),
+ "strip_prefixes": attr.string_list(),
+ "_info_file": attr.label(
+ default = Label("//skylib:more_stable_status.txt"),
+ allow_single_file = True,
+ ),
+ "_template_engine": attr.label(
+ default = Label("//templating:fast_template_engine"),
+ executable = True,
+ cfg = "exec",
+ ),
+ "_template": attr.label(
+ default = Label("//skylib:k8s_gitops.sh.tpl"),
+ allow_single_file = True,
+ ),
+ },
+ executable = True,
+ implementation = _cloudrun_gitops_impl,
+)
+
+def _cloudrun_show_impl(ctx):
+ script_content = """#!/usr/bin/env bash
+set -e
+
+function guess_runfiles() {
+ if [ -d "${BASH_SOURCE[0]}.runfiles" ]; then
+ # Runfiles are adjacent to the current script.
+ echo "$( cd "${BASH_SOURCE[0]}.runfiles" && pwd )"
+ else
+ # The current script is within some other script's runfiles.
+ mydir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+ echo $mydir | sed -e 's|\\(.*\\.runfiles\\)/.*|\\1|'
+ fi
+}
+
+RUNFILES=${RUNFILES:-$(guess_runfiles)}
+"""
+
+ outputs = []
+ script_template = "{template_engine} --template={infile} --variable=PROJECT={project} --variable=REGION={region} --stamp_info_file={info_file}\n"
+ for dep in ctx.attr.src.files.to_list():
+ outputs.append(script_template.format(
+ infile = get_runfile_path(ctx, dep),
+ template_engine = get_runfile_path(ctx, ctx.executable._template_engine),
+ project = ctx.attr.project,
+ region = ctx.attr.region,
+ info_file = get_runfile_path(ctx, ctx.file._info_file),
+ ))
+
+ script_content += "echo '---'\n".join(outputs)
+
+ ctx.actions.write(ctx.outputs.executable, script_content, is_executable = True)
+ return [
+ DefaultInfo(runfiles = ctx.runfiles(files = [ctx.executable._template_engine, ctx.file._info_file] + ctx.files.src)),
+ ]
+
+cloudrun_show = rule(
+ implementation = _cloudrun_show_impl,
+ attrs = {
+ "src": attr.label(
+ doc = "Input file.",
+ mandatory = True,
+ ),
+ "project": attr.string(
+ mandatory = True,
+ ),
+ "region": attr.string(
+ mandatory = True,
+ ),
+ "_info_file": attr.label(
+ default = Label("//skylib:more_stable_status.txt"),
+ allow_single_file = True,
+ ),
+ "_template_engine": attr.label(
+ default = Label("//templating:fast_template_engine"),
+ executable = True,
+ cfg = "exec",
+ ),
+ },
+ executable = True,
+)
+
+def cloudrun_deploy(
+ name,
+ project = None,
+ region = None,
+ manifests = None,
+ name_prefix = None,
+ name_suffix = None,
+ patches = None,
+ image_name_patches = {},
+ image_tag_patches = {},
+ substitutions = {},
+ configurations = [],
+ common_labels = {},
+ common_annotations = {},
+ openapi_path = "@rules_gitops//skylib:run_schema.json",
+ deps = [],
+ deps_aliases = {},
+ images = [],
+ image_digest_tag = False,
+ image_registry = "gcr.io",
+ image_repository = None,
+ gitops = True,
+ gitops_path = "cloud",
+ deployment_branch = None,
+ release_branch_prefix = "main",
+ start_tag = "{{",
+ end_tag = "}}",
+ tags = [],
+ visibility = None):
+ if not manifests:
+ manifests = native.glob(["*.yaml", "*.yaml.tpl"])
+
+ for reservedname in ["PROJECT", "REGION"]:
+ if substitutions.get(reservedname):
+ fail("do not put %s in substitutions parameter of cloudrun_deploy. It will be added autimatically" % reservedname)
+ substitutions = dict(substitutions)
+ substitutions["PROJECT"] = project
+ substitutions["REGION"] = region
+
+ if not gitops:
+ image_pushes = _image_pushes(
+ name_suffix = "-mynamespace.push",
+ images = images,
+ image_registry = image_registry + "/mynamespace",
+ image_repository = image_repository,
+ image_digest_tag = image_digest_tag,
+ tags = tags,
+ )
+ kustomize(
+ name = name,
+ namespace = project,
+ images = image_pushes,
+ manifests = manifests,
+ substitutions = substitutions,
+ deps = deps,
+ deps_aliases = deps_aliases,
+ start_tag = start_tag,
+ end_tag = end_tag,
+ name_prefix = name_prefix,
+ name_suffix = name_suffix,
+ configurations = configurations,
+ common_labels = common_labels,
+ common_annotations = common_annotations,
+ patches = patches,
+ image_name_patches = image_name_patches,
+ image_tag_patches = image_tag_patches,
+ openapi_path = openapi_path,
+ tags = tags,
+ visibility = visibility,
+ )
+ gcloud_run(
+ name = name + ".apply",
+ srcs = [name],
+ project = project,
+ region = region,
+ command = "replace",
+ tags = tags,
+ visibility = visibility,
+ )
+ gcloud_run(
+ name = name + ".delete",
+ srcs = [name],
+ command = "delete",
+ project = project,
+ region = region,
+ push = False,
+ tags = tags,
+ visibility = visibility,
+ )
+ cloudrun_show(
+ name = name + ".show",
+ src = name,
+ project = project,
+ region = region,
+ tags = tags,
+ visibility = visibility,
+ )
+ else:
+ if not region:
+ fail("region must be defined for gitops cloudrun_deploy")
+ if not project:
+ fail("project must be defined for gitops cloudrun_deploy")
+
+ image_pushes = _image_pushes(
+ name_suffix = ".push",
+ images = images,
+ image_registry = image_registry,
+ image_repository = image_repository,
+ image_digest_tag = image_digest_tag,
+ tags = tags,
+ )
+ kustomize(
+ name = name,
+ namespace = project,
+ images = image_pushes,
+ manifests = manifests,
+ visibility = visibility,
+ substitutions = substitutions,
+ deps = deps,
+ deps_aliases = deps_aliases,
+ start_tag = start_tag,
+ end_tag = end_tag,
+ name_prefix = name_prefix,
+ name_suffix = name_suffix,
+ configurations = configurations,
+ common_labels = common_labels,
+ common_annotations = common_annotations,
+ patches = patches,
+ image_name_patches = image_name_patches,
+ image_tag_patches = image_tag_patches,
+ openapi_path = openapi_path,
+ tags = tags,
+ )
+ gcloud_run(
+ name = name + ".apply",
+ srcs = [name],
+ project = project,
+ region = region,
+ command = "replace",
+ tags = tags,
+ visibility = visibility,
+ )
+ gcloud_run(
+ name = name + ".delete",
+ srcs = [name],
+ command = "delete",
+ project = project,
+ region = region,
+ push = False,
+ tags = tags,
+ visibility = visibility,
+ )
+ cloudrun_gitops(
+ name = name + ".gitops",
+ srcs = [name],
+ project = project,
+ region = region,
+ gitops_path = gitops_path,
+ strip_prefixes = [
+ project + "-",
+ region + "-",
+ ],
+ deployment_branch = deployment_branch,
+ release_branch_prefix = release_branch_prefix,
+ tags = tags,
+ visibility = ["//visibility:public"],
+ )
+ cloudrun_show(
+ name = name + ".show",
+ src = name,
+ project = project,
+ region = region,
+ tags = tags,
+ visibility = visibility,
+ )
diff --git a/gitops/cloudrun.sh.tpl b/gitops/cloudrun.sh.tpl
new file mode 100644
index 00000000..ec5a80fa
--- /dev/null
+++ b/gitops/cloudrun.sh.tpl
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# Copyright 2026 The rules_gitops Authors.
+set -eu
+
+function guess_runfiles() {
+ if [ -d ${BASH_SOURCE[0]}.runfiles ]; then
+ # Runfiles are adjacent to the current script.
+ echo "$( cd ${BASH_SOURCE[0]}.runfiles && pwd )"
+ else
+ # The current script is within some other script's runfiles.
+ mydir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+ echo $mydir | sed -e 's|\(.*\.runfiles\)/.*|\1|'
+ fi
+}
+
+RUNFILES="${PYTHON_RUNFILES:-$(guess_runfiles)}"
+
+PIDS=()
+function async() {
+ # Launch the command asynchronously and track its process id.
+ PYTHON_RUNFILES=${RUNFILES} RUNFILES_DIR=${RUNFILES} "$@" &
+ PIDS+=($!)
+}
+
+function waitpids() {
+ # Wait for all of the subprocesses, failing the script if any of them failed.
+ if [ "${#PIDS[@]}" != 0 ]; then
+ for pid in ${PIDS[@]}; do
+ wait ${pid}
+ done
+ fi
+}
+
+PROJECT=%{project}
+REGION=%{region}
+
+%{statements}
diff --git a/gitops/defs.bzl b/gitops/defs.bzl
index 3bbf0ff8..e32ef7d9 100644
--- a/gitops/defs.bzl
+++ b/gitops/defs.bzl
@@ -14,7 +14,9 @@ GitOps rules public interface
load("@rules_gitops//skylib:external_image.bzl", _external_iamge = "external_image")
load("@rules_gitops//skylib:k8s.bzl", _k8s_deploy = "k8s_deploy", _k8s_test_setup = "k8s_test_setup")
+load("@rules_gitops//gitops:cloudrun.bzl", _cloudrun_deploy = "cloudrun_deploy")
k8s_deploy = _k8s_deploy
k8s_test_setup = _k8s_test_setup
external_image = _external_iamge
+cloudrun_deploy = _cloudrun_deploy
diff --git a/gitops/prer/prer_test.sh b/gitops/prer/prer_test.sh
index e5f26a6f..e350bf05 100755
--- a/gitops/prer/prer_test.sh
+++ b/gitops/prer/prer_test.sh
@@ -51,6 +51,7 @@ EXPECTED_TARGETS=(
"//gitops/testing:legacy_alias.gitops"
"//gitops/testing:legacy_label.gitops"
"//gitops/testing:legacy_renamed_alias.gitops"
+ "//gitops/testing:prer_cloudrun_target.gitops"
)
# Define expected push binaries (relative paths under workspace bazel-out/.../bin)
@@ -62,6 +63,7 @@ EXPECTED_PUSH_BINARIES=(
"gitops/testing/skylib_kustomize_tests_img_image_docker_io.push"
"gitops/testing/img_pushed_image"
"gitops/testing/img_pushed_image_docker_io.push"
+ "gitops/testing/push_apply_cloudrun_pushed_image.sh"
)
echo "Verifying gitops targets..."
diff --git a/gitops/testing/BUILD.bazel b/gitops/testing/BUILD.bazel
index cfa50298..570b49b1 100644
--- a/gitops/testing/BUILD.bazel
+++ b/gitops/testing/BUILD.bazel
@@ -11,7 +11,7 @@
load("@aspect_bazel_lib//lib:write_source_files.bzl", "write_source_files")
load("@bazel_skylib//rules:write_file.bzl", "write_file")
load("@rules_shell//shell:sh_test.bzl", "sh_test")
-load("//gitops:defs.bzl", "external_image", "k8s_deploy", "k8s_test_setup")
+load("//gitops:defs.bzl", "cloudrun_deploy", "external_image", "k8s_deploy", "k8s_test_setup")
load("//push_oci:push_oci.bzl", "push_oci")
licenses(["notice"]) # Apache 2.0
@@ -301,3 +301,70 @@ sh_test(
"REGISTRY_BIN": "$(location @com_github_google_go_containerregistry//cmd/registry)",
},
)
+
+push_oci(
+ name = "apply_cloudrun_pushed_image",
+ image = "//skylib/kustomize/tests:image",
+ registry = "localhost:1338",
+ remote_tags = ":apply_tags_file",
+ repository = "repo/apply_cloudrun_pushed_image",
+)
+
+cloudrun_deploy(
+ name = "apply_cloudrun_test_target",
+ project = "ci",
+ region = "us-central1",
+ gitops = False,
+ images = {
+ "//skylib/tests/cloudrun:image": ":apply_cloudrun_pushed_image",
+ },
+ manifests = [
+ "//skylib/tests/cloudrun:run.yaml",
+ ":run_second.yaml",
+ ],
+ visibility = ["//visibility:public"],
+)
+
+sh_test(
+ name = "cloudrun_apply_test",
+ size = "small",
+ srcs = ["cloudrun_apply_test.sh"],
+ tags = ["exclusive"],
+ args = [
+ "$(location :apply_cloudrun_test_target.apply)",
+ "$(location :apply_cloudrun_test_target.delete)",
+ "localhost:1338/repo/apply_cloudrun_pushed_image:testtag",
+ "localhost:1338/repo/apply_cloudrun_pushed_image",
+ "ci",
+ "us-central1",
+ "webhook",
+ "webhook-secondary",
+ ],
+ data = [
+ ":apply_cloudrun_test_target.apply",
+ ":apply_cloudrun_test_target.delete",
+ "@com_github_google_go_containerregistry//cmd/crane",
+ "@com_github_google_go_containerregistry//cmd/registry",
+ ],
+ env = {
+ "CRANE_BIN": "$(location @com_github_google_go_containerregistry//cmd/crane)",
+ "REGISTRY_BIN": "$(location @com_github_google_go_containerregistry//cmd/registry)",
+ },
+)
+
+cloudrun_deploy(
+ name = "prer_cloudrun_target",
+ project = "ci",
+ region = "us-central1",
+ deployment_branch = "test1",
+ gitops = True,
+ images = {
+ "//skylib/tests/cloudrun:image": ":apply_cloudrun_pushed_image",
+ },
+ manifests = [
+ "//skylib/tests/cloudrun:run.yaml",
+ ],
+ release_branch_prefix = "gitops_test_release_branch",
+ visibility = ["//visibility:public"],
+)
+
diff --git a/gitops/testing/cloudrun_apply_test.sh b/gitops/testing/cloudrun_apply_test.sh
new file mode 100755
index 00000000..b6077b88
--- /dev/null
+++ b/gitops/testing/cloudrun_apply_test.sh
@@ -0,0 +1,104 @@
+#!/bin/bash -x
+#
+# Integration test for cloudrun_deploy .apply and .delete targets using mock gcloud.
+#
+
+apply_bin=$1
+delete_bin=$2
+expected_image_ref=$3
+expected_manifest_sub=$4
+expected_project=$5
+expected_region=$6
+expected_services="${@:7}"
+
+# Start the local in-memory registry on the default port 1338
+${REGISTRY_BIN} &
+registry_pid=$!
+trap "kill -9 $registry_pid" EXIT
+
+# Wait for registry to start up
+for i in {1..50}; do
+ if curl -s -f http://localhost:1338/v2/ >/dev/null; then
+ break
+ fi
+ sleep 0.05
+done
+
+# Create a temporary directory for the mock gcloud and manifest dump
+tmp_dir=$(mktemp -d)
+trap "rm -rf $tmp_dir; kill -9 $registry_pid" EXIT
+
+manifest_out="${tmp_dir}/applied_manifest.yaml"
+gcloud_log="${tmp_dir}/gcloud.log"
+mock_gcloud="${tmp_dir}/gcloud"
+
+cat < "${mock_gcloud}"
+#!/bin/bash
+echo "gcloud \$@" >> "${gcloud_log}"
+has_replace=false
+for arg in "\$@"; do
+ if [ "\$arg" = "replace" ]; then
+ has_replace=true
+ fi
+done
+
+if [ "\$has_replace" = true ]; then
+ cat > "${manifest_out}"
+fi
+EOF
+
+chmod +x "${mock_gcloud}"
+
+# Prepend mock gcloud to PATH
+export PATH="${tmp_dir}:${PATH}"
+
+# 1. Execute the apply target executable
+${apply_bin}
+
+# Verify that the image is successfully pushed using crane
+${CRANE_BIN} validate -v --fast --remote ${expected_image_ref}
+
+# Verify that the manifest was applied and has the expected image reference
+if [ ! -f "${manifest_out}" ]; then
+ echo "Error: Applied manifest file was not created by mock gcloud"
+ exit 1
+fi
+
+cat "${manifest_out}"
+
+if ! grep -q "${expected_manifest_sub}" "${manifest_out}"; then
+ echo "Error: Applied manifest does not contain expected substring '${expected_manifest_sub}'"
+ exit 1
+fi
+
+# Verify gcloud apply arguments
+expected_apply_args="gcloud run services replace - --project=${expected_project} --region=${expected_region}"
+if ! grep -Fq "${expected_apply_args}" "${gcloud_log}"; then
+ echo "Error: gcloud was not called with the expected replace arguments: ${expected_apply_args}"
+ echo "gcloud log contents:"
+ cat "${gcloud_log}"
+ exit 1
+fi
+
+num_replaces=$(grep -c "gcloud run services replace" "${gcloud_log}")
+if [ "${num_replaces}" -ne 1 ]; then
+ echo "Error: expected gcloud run services replace to be called exactly once, but it was called ${num_replaces} times"
+ cat "${gcloud_log}"
+ exit 1
+fi
+
+# 2. Execute the delete target executable
+${delete_bin}
+
+# Verify gcloud delete arguments
+for service in ${expected_services}; do
+ expected_delete_args="gcloud run services delete ${service} --project=${expected_project} --region=${expected_region}"
+ if ! grep -Fq "${expected_delete_args}" "${gcloud_log}"; then
+ echo "Error: gcloud was not called with the expected delete arguments: ${expected_delete_args}"
+ echo "gcloud log contents:"
+ cat "${gcloud_log}"
+ exit 1
+ fi
+done
+
+echo "Success!"
diff --git a/gitops/testing/run_second.yaml b/gitops/testing/run_second.yaml
new file mode 100644
index 00000000..c2f9a91d
--- /dev/null
+++ b/gitops/testing/run_second.yaml
@@ -0,0 +1,14 @@
+apiVersion: serving.knative.dev/v1
+kind: Service
+metadata:
+ name: webhook-secondary
+ labels:
+ cloud.googleapis.com/location: us-central1
+spec:
+ template:
+ spec:
+ containers:
+ - name: main
+ image: //skylib/tests/cloudrun:image
+ ports:
+ - containerPort: 8080
diff --git a/skylib/tests/cloudrun/BUILD.bazel b/skylib/tests/cloudrun/BUILD.bazel
index 390a2cf0..c96e6ecd 100644
--- a/skylib/tests/cloudrun/BUILD.bazel
+++ b/skylib/tests/cloudrun/BUILD.bazel
@@ -1,5 +1,7 @@
load("@aspect_bazel_lib//lib:write_source_files.bzl", "write_source_files")
-load("@rules_gitops//gitops:defs.bzl", "external_image", "k8s_deploy")
+load("@rules_gitops//gitops:defs.bzl", "cloudrun_deploy", "external_image")
+
+exports_files(["run.yaml"])
external_image(
name = "image",
@@ -7,13 +9,13 @@ external_image(
image = "gcr.io/repo/someimage:thetag",
)
-k8s_deploy(
+cloudrun_deploy(
name = "prod",
gitops = False,
images = [":image"],
manifests = ["run.yaml"],
- namespace = "cloudrun_project",
- openapi_path = "//skylib:run_schema.json",
+ project = "cloudrun_project",
+ region = "us-central1",
patches = ["run_patch.yaml"],
)