3939 env :
4040 CLOUD_RUN_REGION : ${{ vars.CLOUD_RUN_REGION }}
4141 CLOUD_RUN_SERVICE : ${{ vars.CLOUD_RUN_SERVICE }}
42+ CLOUD_SCHEDULER_LOCATION : ${{ vars.CLOUD_SCHEDULER_LOCATION }}
4243 steps :
4344 - name : Validate inputs
4445 run : |
@@ -82,20 +83,127 @@ jobs:
8283 raw_path="/${raw_path}"
8384 fi
8485
85- service_url ="$(
86+ service_json ="$(
8687 gcloud run services describe "${CLOUD_RUN_SERVICE}" \
8788 --region "${CLOUD_RUN_REGION}" \
88- --format='value(status.url)'
89+ --format=json
90+ )"
91+ service_url="$(SERVICE_JSON="${service_json}" python3 - <<'PY'
92+ import json
93+ import os
94+
95+ service = json.loads(os.environ["SERVICE_JSON"])
96+ print((service.get("status") or {}).get("url") or "")
97+ PY
8998 )"
9099 if [ -z "${service_url}" ]; then
91100 echo "Unable to resolve Cloud Run service URL." >&2
92101 exit 1
93102 fi
94103
104+ service_ingress="$(SERVICE_JSON="${service_json}" python3 - <<'PY'
105+ import json
106+ import os
107+
108+ service = json.loads(os.environ["SERVICE_JSON"])
109+ annotations = (service.get("metadata") or {}).get("annotations") or {}
110+ print(annotations.get("run.googleapis.com/ingress-status") or annotations.get("run.googleapis.com/ingress") or "")
111+ PY
112+ )"
113+ latest_ready_revision="$(SERVICE_JSON="${service_json}" python3 - <<'PY'
114+ import json
115+ import os
116+
117+ service = json.loads(os.environ["SERVICE_JSON"])
118+ print((service.get("status") or {}).get("latestReadyRevisionName") or "")
119+ PY
120+ )"
121+ deployed_commit="$(SERVICE_JSON="${service_json}" python3 - <<'PY'
122+ import json
123+ import os
124+
125+ service = json.loads(os.environ["SERVICE_JSON"])
126+ template = ((service.get("spec") or {}).get("template") or {}).get("metadata") or {}
127+ print((template.get("labels") or {}).get("commit-sha") or "")
128+ PY
129+ )"
130+
131+ invoke_method="direct"
132+ scheduler_job=""
133+ scheduler_location=""
134+ if [ "${service_ingress}" = "internal" ]; then
135+ scheduler_location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}"
136+ case "${raw_path}" in
137+ /)
138+ scheduler_job="${CLOUD_RUN_SERVICE}-scheduler"
139+ ;;
140+ /probe)
141+ scheduler_job="${CLOUD_RUN_SERVICE}-probe-scheduler"
142+ ;;
143+ /precheck)
144+ scheduler_job="${CLOUD_RUN_SERVICE}-precheck-scheduler"
145+ ;;
146+ *)
147+ echo "Cloud Run service ${CLOUD_RUN_SERVICE} has internal ingress, so GitHub-hosted runners cannot curl ${raw_path} directly." >&2
148+ echo "Use one of the scheduler-backed paths: /, /probe, /precheck." >&2
149+ exit 1
150+ ;;
151+ esac
152+
153+ scheduler_uri="$(
154+ gcloud scheduler jobs describe "${scheduler_job}" \
155+ --location="${scheduler_location}" \
156+ --format='value(httpTarget.uri)' 2>/dev/null || true
157+ )"
158+ if [ -z "${scheduler_uri}" ]; then
159+ echo "Cloud Scheduler job ${scheduler_job} was not found in ${scheduler_location}." >&2
160+ exit 1
161+ fi
162+ scheduler_path="$(SCHEDULER_URI="${scheduler_uri}" python3 - <<'PY'
163+ import os
164+ from urllib.parse import urlparse
165+
166+ def normalize(path: str) -> str:
167+ clean = (path or "/").rstrip("/")
168+ return clean or "/"
169+
170+ print(normalize(urlparse(os.environ["SCHEDULER_URI"]).path))
171+ PY
172+ )"
173+ requested_path="$(RAW_PATH="${raw_path}" python3 - <<'PY'
174+ import os
175+
176+ clean = (os.environ["RAW_PATH"] or "/").rstrip("/")
177+ print(clean or "/")
178+ PY
179+ )"
180+ if [ "${scheduler_path}" != "${requested_path}" ]; then
181+ echo "Cloud Scheduler job ${scheduler_job} targets ${scheduler_uri}, not ${raw_path}." >&2
182+ exit 1
183+ fi
184+ invoke_method="scheduler"
185+ fi
186+
187+ echo "Cloud Run service: ${CLOUD_RUN_SERVICE}"
188+ echo "Cloud Run region: ${CLOUD_RUN_REGION}"
189+ echo "Cloud Run URL: ${service_url}"
190+ echo "Cloud Run ingress: ${service_ingress:-<empty>}"
191+ echo "Latest ready revision: ${latest_ready_revision:-<empty>}"
192+ echo "Deployed commit: ${deployed_commit:-<empty>}"
193+ echo "Invoke method: ${invoke_method}"
194+ if [ -n "${scheduler_job}" ]; then
195+ echo "Cloud Scheduler job: ${scheduler_job}"
196+ echo "Cloud Scheduler location: ${scheduler_location}"
197+ fi
198+
95199 echo "url=${service_url}" >> "$GITHUB_OUTPUT"
96200 echo "path=${raw_path}" >> "$GITHUB_OUTPUT"
201+ echo "invoke_method=${invoke_method}" >> "$GITHUB_OUTPUT"
202+ echo "scheduler_job=${scheduler_job}" >> "$GITHUB_OUTPUT"
203+ echo "scheduler_location=${scheduler_location}" >> "$GITHUB_OUTPUT"
97204
98205 - name : Authenticate for service invocation
206+ if : steps.service.outputs.invoke_method == 'direct'
99207 id : invoke-auth
100208 uses : google-github-actions/auth@v3
101209 with :
@@ -106,10 +214,91 @@ jobs:
106214 id_token_include_email : true
107215
108216 - name : Invoke service
217+ if : steps.service.outputs.invoke_method == 'direct'
109218 run : |
110219 set -euo pipefail
111220
112221 curl --fail-with-body --show-error --silent \
113222 --request POST \
114223 --header "Authorization: Bearer ${{ steps.invoke-auth.outputs.id_token }}" \
115224 "${{ steps.service.outputs.url }}${{ steps.service.outputs.path }}"
225+
226+ - name : Invoke internal service through Cloud Scheduler
227+ if : steps.service.outputs.invoke_method == 'scheduler'
228+ run : |
229+ set -euo pipefail
230+
231+ started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
232+ scheduler_job="${{ steps.service.outputs.scheduler_job }}"
233+ scheduler_location="${{ steps.service.outputs.scheduler_location }}"
234+
235+ echo "Triggering ${scheduler_job} at ${started_at}."
236+ gcloud scheduler jobs run "${scheduler_job}" \
237+ --location="${scheduler_location}" \
238+ --quiet
239+
240+ deadline=$((SECONDS + 180))
241+ while true; do
242+ job_json="$(
243+ gcloud scheduler jobs describe "${scheduler_job}" \
244+ --location="${scheduler_location}" \
245+ --format=json
246+ )"
247+ attempt_seen="$(JOB_JSON="${job_json}" STARTED_AT="${started_at}" python3 - <<'PY'
248+ import datetime as dt
249+ import json
250+ import os
251+
252+ def parse_timestamp(value: str) -> dt.datetime | None:
253+ if not value:
254+ return None
255+ text = value.replace("Z", "+00:00")
256+ return dt.datetime.fromisoformat(text)
257+
258+ job = json.loads(os.environ["JOB_JSON"])
259+ last_attempt = parse_timestamp(job.get("lastAttemptTime") or "")
260+ started_at = parse_timestamp(os.environ["STARTED_AT"])
261+ print("true" if last_attempt and started_at and last_attempt >= started_at else "false")
262+ PY
263+ )"
264+ status_code="$(JOB_JSON="${job_json}" python3 - <<'PY'
265+ import json
266+ import os
267+
268+ status = (json.loads(os.environ["JOB_JSON"]).get("status") or {})
269+ print(status.get("code") or "")
270+ PY
271+ )"
272+ status_message="$(JOB_JSON="${job_json}" python3 - <<'PY'
273+ import json
274+ import os
275+
276+ status = (json.loads(os.environ["JOB_JSON"]).get("status") or {})
277+ print(status.get("message") or "")
278+ PY
279+ )"
280+ last_attempt_time="$(JOB_JSON="${job_json}" python3 - <<'PY'
281+ import json
282+ import os
283+
284+ print(json.loads(os.environ["JOB_JSON"]).get("lastAttemptTime") or "")
285+ PY
286+ )"
287+
288+ if [ "${attempt_seen}" = "true" ]; then
289+ if [ -n "${status_code}" ] && [ "${status_code}" != "0" ]; then
290+ echo "Cloud Scheduler job ${scheduler_job} failed with status ${status_code}: ${status_message}" >&2
291+ exit 1
292+ fi
293+ echo "Cloud Scheduler job ${scheduler_job} ran at ${last_attempt_time}."
294+ break
295+ fi
296+
297+ if [ "${SECONDS}" -ge "${deadline}" ]; then
298+ echo "Timed out waiting for Cloud Scheduler job ${scheduler_job} to record a new attempt." >&2
299+ exit 1
300+ fi
301+
302+ echo "Waiting for Cloud Scheduler job ${scheduler_job} attempt..."
303+ sleep 10
304+ done
0 commit comments