Skip to content

Merge Train Runner #586

Merge Train Runner

Merge Train Runner #586

---
name: Merge Train Runner
"on":
schedule:
- cron: "*/5 * * * *"
workflow_dispatch:
inputs:
repository:
description: >-
owner/name repository to run through Launchplane merge train
required: false
default: ""
base_branch:
description: Base branch protected by the merge train
required: false
default: main
mutate:
description: >-
Apply one worker mutation when admitted. Scheduled runs use
LAUNCHPLANE_MERGE_TRAIN_MUTATE instead.
type: boolean
required: false
default: false
runner_mode:
description: >-
Which admitted scheduler entrypoint to call: level1 or controller.
Scheduled runs use LAUNCHPLANE_MERGE_TRAIN_RUNNER_MODE instead.
type: choice
required: false
default: level1
options:
- level1
- controller
batch_candidate_mode:
description: >-
Optional batch-candidate phase to run instead of the Level 1 worker:
none, plan, build, or observe.
type: choice
required: false
default: none
options:
- none
- plan
- build
- observe
batch_candidate_record_id:
description: Candidate record id required for build or observe mode
required: false
default: ""
batch_landing_mode:
description: >-
Optional batch-landing phase to run instead of the Level 1 worker:
none, plan, or land.
type: choice
required: false
default: none
options:
- none
- plan
- land
batch_landing_candidate_record_id:
description: Candidate record id required for landing plan mode
required: false
default: ""
batch_landing_plan_record_id:
description: Landing-plan record id required for land mode
required: false
default: ""
stack_collapse_mode:
description: >-
Optional stack-collapse phase to run instead of the Level 1 worker:
none, execute, or admit.
type: choice
required: false
default: none
options:
- none
- execute
- admit
stack_collapse_plan_record_id:
description: >-
Stack-collapse plan record id required for execute or admit mode
required: false
default: ""
permissions:
contents: read
id-token: write
concurrency:
group: >-
launchplane-merge-train
cancel-in-progress: false
jobs:
run:
if: >-
github.event_name != 'schedule' ||
vars.LAUNCHPLANE_MERGE_TRAIN_REPOSITORY != ''
runs-on:
- self-hosted
- ${{ vars.LAUNCHPLANE_RUNNER_LABEL }}
env:
LAUNCHPLANE_URL: >-
${{ vars.LAUNCHPLANE_PUBLIC_URL ||
'https://launchplane.shinycomputers.com' }}
MERGE_TRAIN_REPOSITORY: >-
${{ inputs.repository || vars.LAUNCHPLANE_MERGE_TRAIN_REPOSITORY }}
MERGE_TRAIN_BASE_BRANCH: ${{ inputs.base_branch || 'main' }}
MERGE_TRAIN_MUTATE: >-
${{
(
github.event_name == 'schedule' &&
vars.LAUNCHPLANE_MERGE_TRAIN_MUTATE == 'true'
) && 'true' ||
(
github.event_name != 'schedule' && inputs.mutate
) && 'true' ||
'false'
}}
MERGE_TRAIN_RUNNER_MODE: >-
${{
github.event_name == 'schedule' &&
(vars.LAUNCHPLANE_MERGE_TRAIN_RUNNER_MODE || 'level1') ||
(inputs.runner_mode || 'level1')
}}
MERGE_TRAIN_BATCH_CANDIDATE_MODE: >-
${{
github.event_name != 'schedule' &&
inputs.batch_candidate_mode ||
'none'
}}
MERGE_TRAIN_BATCH_CANDIDATE_RECORD_ID: >-
${{
github.event_name != 'schedule' &&
inputs.batch_candidate_record_id ||
''
}}
MERGE_TRAIN_BATCH_LANDING_MODE: >-
${{
github.event_name != 'schedule' &&
inputs.batch_landing_mode ||
'none'
}}
MERGE_TRAIN_BATCH_LANDING_CANDIDATE_RECORD_ID: >-
${{
github.event_name != 'schedule' &&
inputs.batch_landing_candidate_record_id ||
''
}}
MERGE_TRAIN_BATCH_LANDING_PLAN_RECORD_ID: >-
${{
github.event_name != 'schedule' &&
inputs.batch_landing_plan_record_id ||
''
}}
MERGE_TRAIN_STACK_COLLAPSE_MODE: >-
${{
github.event_name != 'schedule' &&
inputs.stack_collapse_mode ||
'none'
}}
MERGE_TRAIN_STACK_COLLAPSE_PLAN_RECORD_ID: >-
${{
github.event_name != 'schedule' &&
inputs.stack_collapse_plan_record_id ||
''
}}
steps:
- name: Request admission decision
id: admission
shell: bash
run: |
set -euo pipefail
: "${LAUNCHPLANE_URL:?Missing LAUNCHPLANE_PUBLIC_URL variable}"
: "${MERGE_TRAIN_REPOSITORY:?Set repository input or repo variable}"
: "${MERGE_TRAIN_BASE_BRANCH:?Missing base branch}"
service_origin="$({
python3 - <<'PY'
import os
from urllib.parse import urlsplit
parsed = urlsplit(os.environ["LAUNCHPLANE_URL"].strip())
if not parsed.scheme or not parsed.netloc or not parsed.hostname:
raise SystemExit("LAUNCHPLANE_URL must be an absolute URL.")
print(f"{parsed.scheme}://{parsed.netloc}")
PY
})"
service_audience="$({
python3 - <<'PY'
import os
from urllib.parse import urlsplit
parsed = urlsplit(os.environ["LAUNCHPLANE_URL"].strip())
if not parsed.hostname:
raise SystemExit("LAUNCHPLANE_URL must include a hostname.")
print(parsed.hostname)
PY
})"
query_string="$({
python3 - <<'PY'
import os
from urllib.parse import urlencode
print(urlencode({
"repository": os.environ["MERGE_TRAIN_REPOSITORY"].strip(),
"base_branch": os.environ["MERGE_TRAIN_BASE_BRANCH"].strip(),
}))
PY
})"
oidc_token="$({
curl -fsSL \
-H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${service_audience}" \
| jq -r '.value'
})"
response_file="launchplane-merge-train-admission.json"
status_code="$(curl -sS \
-o "$response_file" \
-w '%{http_code}' \
-H "Authorization: Bearer ${oidc_token}" \
"${service_origin}/v1/work-graph/merge-train/admission?${query_string}"
)"
if [ "$status_code" != "200" ]; then
cat "$response_file" >&2
echo "Merge-train admission failed with HTTP ${status_code}." >&2
exit 1
fi
jq . "$response_file"
admission_status="$(jq -r '.admission.status' "$response_file")"
reason_code="$(jq -r '.admission.reason_code' "$response_file")"
next_allowed_at="$(
jq -r '.admission.next_allowed_at' "$response_file"
)"
{
echo "service_origin=${service_origin}"
echo "service_audience=${service_audience}"
echo "admission_status=${admission_status}"
echo "reason_code=${reason_code}"
echo "next_allowed_at=${next_allowed_at}"
} >> "$GITHUB_OUTPUT"
{
echo '## Launchplane merge train admission'
echo
echo "- Repository: ${MERGE_TRAIN_REPOSITORY}"
echo "- Base branch: ${MERGE_TRAIN_BASE_BRANCH}"
echo "- Runner mode: ${MERGE_TRAIN_RUNNER_MODE}"
echo "- Status: ${admission_status}"
echo "- Reason: ${reason_code}"
echo "- Next allowed: ${next_allowed_at}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Validate merge-train runner mode
if: steps.admission.outputs.admission_status == 'admitted'
shell: bash
run: |
set -euo pipefail
case "$MERGE_TRAIN_RUNNER_MODE" in
level1|controller) ;;
*)
echo "Unsupported merge-train runner mode:" \
"${MERGE_TRAIN_RUNNER_MODE}" >&2
exit 1
;;
esac
- name: Checkout runner helpers
if: >-
steps.admission.outputs.admission_status == 'admitted' &&
(env.MERGE_TRAIN_RUNNER_MODE == 'controller' ||
env.MERGE_TRAIN_BATCH_CANDIDATE_MODE != 'none' ||
env.MERGE_TRAIN_BATCH_LANDING_MODE != 'none' ||
env.MERGE_TRAIN_STACK_COLLAPSE_MODE != 'none')
uses: actions/checkout@v6
- name: Run one merge-train pass
if: >-
steps.admission.outputs.admission_status == 'admitted' &&
env.MERGE_TRAIN_RUNNER_MODE == 'level1'
shell: bash
env:
SERVICE_ORIGIN: ${{ steps.admission.outputs.service_origin }}
SERVICE_AUDIENCE: ${{ steps.admission.outputs.service_audience }}
run: |
set -euo pipefail
if [ "${MERGE_TRAIN_BATCH_CANDIDATE_MODE}" != "none" ] || \
[ "${MERGE_TRAIN_BATCH_LANDING_MODE}" != "none" ] || \
[ "${MERGE_TRAIN_STACK_COLLAPSE_MODE}" != "none" ]; then
echo "Batch mode selected; skipping Level 1 worker pass."
exit 0
fi
oidc_token="$({
curl -fsSL \
-H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${SERVICE_AUDIENCE}" \
| jq -r '.value'
})"
request_payload="$({
jq -n \
--arg repository "$MERGE_TRAIN_REPOSITORY" \
--arg base_branch "$MERGE_TRAIN_BASE_BRANCH" \
--argjson mutate "$MERGE_TRAIN_MUTATE" \
'{
schema_version: 1,
repository: $repository,
base_branch: $base_branch,
mutate: $mutate
}'
})"
response_file="launchplane-merge-train-run-once.json"
status_code="$(curl -sS \
-o "$response_file" \
-w '%{http_code}' \
-X POST \
-H "Authorization: Bearer ${oidc_token}" \
-H 'Content-Type: application/json' \
--data "$request_payload" \
"${SERVICE_ORIGIN}/v1/work-graph/merge-train/run-once")"
if [ "$status_code" != "202" ]; then
cat "$response_file" >&2
echo "Merge-train run-once failed with HTTP ${status_code}." >&2
exit 1
fi
jq . "$response_file"
{
echo
echo '## Launchplane merge train run-once'
echo
echo "- Mode: $(jq -r '.result.mode' "$response_file")"
run_record="$(
jq -r '.records.merge_train_run_id // ""' "$response_file"
)"
next_action="$({
jq -r \
'.result.dry_run_result.intended_next_action //
.result.intended_next_action // "unknown"' \
"$response_file"
})"
echo "- Run record: ${run_record}"
echo "- Next action: ${next_action}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Validate merge-train phase selection
if: steps.admission.outputs.admission_status == 'admitted'
shell: bash
run: |
set -euo pipefail
selected_modes=()
if [ "${MERGE_TRAIN_BATCH_CANDIDATE_MODE}" != "none" ]; then
selected_modes+=("batch-candidate:${MERGE_TRAIN_BATCH_CANDIDATE_MODE}")
fi
if [ "${MERGE_TRAIN_BATCH_LANDING_MODE}" != "none" ]; then
selected_modes+=("batch-landing:${MERGE_TRAIN_BATCH_LANDING_MODE}")
fi
if [ "${MERGE_TRAIN_STACK_COLLAPSE_MODE}" != "none" ]; then
selected_modes+=("stack-collapse:${MERGE_TRAIN_STACK_COLLAPSE_MODE}")
fi
if [ "${MERGE_TRAIN_RUNNER_MODE}" = "controller" ] && \
[ "${#selected_modes[@]}" -gt 0 ]; then
echo "Controller mode cannot be combined with phase inputs." >&2
printf 'Selected modes: %s\n' "${selected_modes[*]}" >&2
exit 1
fi
if [ "${#selected_modes[@]}" -gt 1 ]; then
echo "Only one merge-train phase may run per workflow dispatch." >&2
printf 'Selected modes: %s\n' "${selected_modes[*]}" >&2
exit 1
fi
- name: Run one merge-train controller action
if: >-
steps.admission.outputs.admission_status == 'admitted' &&
env.MERGE_TRAIN_RUNNER_MODE == 'controller'
shell: bash
env:
SERVICE_ORIGIN: ${{ steps.admission.outputs.service_origin }}
SERVICE_AUDIENCE: ${{ steps.admission.outputs.service_audience }}
run: |
set -euo pipefail
oidc_token="$({
curl -fsSL \
-H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${SERVICE_AUDIENCE}" \
| jq -r '.value'
})"
request_payload="$({
jq -n \
--arg repository "$MERGE_TRAIN_REPOSITORY" \
--arg base_branch "$MERGE_TRAIN_BASE_BRANCH" \
--argjson mutate "$MERGE_TRAIN_MUTATE" \
'{
schema_version: 1,
repository: $repository,
base_branch: $base_branch,
mutate: $mutate
}'
})"
response_file="launchplane-merge-train-controller-run-once.json"
controller_url="${SERVICE_ORIGIN}/v1/work-graph/merge-train"
controller_url="${controller_url}/controller/run-once"
status_code="$(curl -sS \
-o "$response_file" \
-w '%{http_code}' \
-X POST \
-H "Authorization: Bearer ${oidc_token}" \
-H 'Content-Type: application/json' \
--data "$request_payload" \
"$controller_url")"
if [ "$status_code" != "202" ]; then
cat "$response_file" >&2
echo "Merge-train controller run-once failed" \
"with HTTP ${status_code}." >&2
exit 1
fi
jq . "$response_file"
feedback_payloads="launchplane-merge-train-controller-feedback.json"
python3 scripts/merge_train_controller_feedback.py \
--response-file "$response_file" \
> "$feedback_payloads"
feedback_count="$(jq 'length' "$feedback_payloads")"
if [ "$MERGE_TRAIN_MUTATE" != "true" ]; then
feedback_count=0
fi
if [ "$feedback_count" -gt 0 ]; then
feedback_url="${SERVICE_ORIGIN}/v1/work-graph/merge-train/pr-feedback"
feedback_index=0
while IFS= read -r feedback_payload; do
feedback_index="$((feedback_index + 1))"
feedback_response="launchplane-merge-train-feedback-${feedback_index}.json"
feedback_payload_digest="$(
printf '%s' "$feedback_payload" | sha256sum | cut -d' ' -f1
)"
feedback_idempotency_key="merge-train-feedback:controller"
feedback_idempotency_key="${feedback_idempotency_key}:${GITHUB_RUN_ID}"
feedback_idempotency_key="${feedback_idempotency_key}:${feedback_payload_digest}"
feedback_status="$(curl -sS \
-o "$feedback_response" \
-w '%{http_code}' \
-X POST \
-H "Authorization: Bearer ${oidc_token}" \
-H "Idempotency-Key: ${feedback_idempotency_key}" \
-H 'Content-Type: application/json' \
--data "$feedback_payload" \
"$feedback_url")"
if [ "$feedback_status" != "202" ]; then
cat "$feedback_response" >&2
echo "Merge-train PR feedback failed" \
"with HTTP ${feedback_status}." >&2
exit 1
fi
jq . "$feedback_response"
done < <(jq -c '.[]' "$feedback_payloads")
fi
{
echo
echo '## Launchplane merge train controller'
echo
echo "- Mode: $(jq -r '.result.mode' "$response_file")"
controller_action="$(
jq -r '.result.controller_action // "unknown"' "$response_file"
)"
candidate_record="$(
jq -r '.records.merge_train_batch_candidate_record_id // ""' \
"$response_file"
)"
landing_record="$(
jq -r '.records.merge_train_batch_landing_plan_record_id // ""' \
"$response_file"
)"
stack_record="$(
jq -r '.records.merge_train_stack_collapse_plan_record_id // ""' \
"$response_file"
)"
echo "- Controller action: ${controller_action}"
echo "- Candidate record: ${candidate_record}"
echo "- Landing-plan record: ${landing_record}"
echo "- Stack-collapse record: ${stack_record}"
echo "- Feedback payloads: ${feedback_count}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Run one batch-candidate phase
id: batch_candidate
if: >-
steps.admission.outputs.admission_status == 'admitted' &&
env.MERGE_TRAIN_BATCH_CANDIDATE_MODE != 'none'
shell: bash
env:
SERVICE_ORIGIN: ${{ steps.admission.outputs.service_origin }}
SERVICE_AUDIENCE: ${{ steps.admission.outputs.service_audience }}
run: |
set -euo pipefail
case "$MERGE_TRAIN_BATCH_CANDIDATE_MODE" in
plan|build|observe) ;;
*)
echo "Unsupported batch candidate mode:" \
"${MERGE_TRAIN_BATCH_CANDIDATE_MODE}" >&2
exit 1
;;
esac
if [ "$MERGE_TRAIN_BATCH_CANDIDATE_MODE" != "plan" ] && \
[ -z "${MERGE_TRAIN_BATCH_CANDIDATE_RECORD_ID}" ]; then
echo \
"batch_candidate_record_id is required for build or observe." \
>&2
exit 1
fi
oidc_token="$({
curl -fsSL \
-H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${SERVICE_AUDIENCE}" \
| jq -r '.value'
})"
request_payload="$({
jq -n \
--arg repository "$MERGE_TRAIN_REPOSITORY" \
--arg base_branch "$MERGE_TRAIN_BASE_BRANCH" \
--arg mode "$MERGE_TRAIN_BATCH_CANDIDATE_MODE" \
--arg candidate_record_id \
"$MERGE_TRAIN_BATCH_CANDIDATE_RECORD_ID" \
'{
schema_version: 1,
repository: $repository,
base_branch: $base_branch,
mode: $mode,
candidate_record_id: $candidate_record_id
}'
})"
response_file="launchplane-merge-train-batch-candidate.json"
batch_candidate_url="${SERVICE_ORIGIN}/v1/work-graph/merge-train"
batch_candidate_url="${batch_candidate_url}/batch-candidate/run-once"
status_code="$(curl -sS \
-o "$response_file" \
-w '%{http_code}' \
-X POST \
-H "Authorization: Bearer ${oidc_token}" \
-H 'Content-Type: application/json' \
--data "$request_payload" \
"$batch_candidate_url")"
if [ "$status_code" != "202" ]; then
cat "$response_file" >&2
echo "Merge-train batch-candidate phase failed" \
"with HTTP ${status_code}." >&2
exit 1
fi
jq . "$response_file"
echo "response_file=${response_file}" >> "$GITHUB_OUTPUT"
{
echo
echo '## Launchplane merge train batch candidate'
echo
echo "- Mode: $(jq -r '.result.mode' "$response_file")"
candidate_status="$(
jq -r '.result.candidate.status' "$response_file"
)"
candidate_sha="$(
jq -r '.result.candidate.candidate_sha // ""' "$response_file"
)"
required_checks="$(
jq -r '.result.candidate.required_checks_status' "$response_file"
)"
candidate_record="$(
jq -r '.records.merge_train_batch_candidate_record_id' \
"$response_file"
)"
echo "- Candidate status: ${candidate_status}"
echo "- Candidate SHA: ${candidate_sha}"
echo "- Required checks: ${required_checks}"
echo "- Record: ${candidate_record}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Run one stack-collapse phase
id: stack_collapse
if: >-
steps.admission.outputs.admission_status == 'admitted' &&
env.MERGE_TRAIN_STACK_COLLAPSE_MODE != 'none'
shell: bash
env:
SERVICE_ORIGIN: ${{ steps.admission.outputs.service_origin }}
SERVICE_AUDIENCE: ${{ steps.admission.outputs.service_audience }}
run: |
set -euo pipefail
case "$MERGE_TRAIN_STACK_COLLAPSE_MODE" in
execute|admit) ;;
*)
echo "Unsupported stack collapse mode:" \
"${MERGE_TRAIN_STACK_COLLAPSE_MODE}" >&2
exit 1
;;
esac
if [ -z "${MERGE_TRAIN_STACK_COLLAPSE_PLAN_RECORD_ID}" ]; then
echo \
"stack_collapse_plan_record_id is required." \
>&2
exit 1
fi
oidc_token="$({
curl -fsSL \
-H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${SERVICE_AUDIENCE}" \
| jq -r '.value'
})"
request_payload="$({
jq -n \
--arg repository "$MERGE_TRAIN_REPOSITORY" \
--arg base_branch "$MERGE_TRAIN_BASE_BRANCH" \
--arg mode "$MERGE_TRAIN_STACK_COLLAPSE_MODE" \
--arg stack_collapse_plan_record_id \
"$MERGE_TRAIN_STACK_COLLAPSE_PLAN_RECORD_ID" \
'{
schema_version: 1,
repository: $repository,
base_branch: $base_branch,
mode: $mode,
stack_collapse_plan_record_id: $stack_collapse_plan_record_id
}'
})"
response_file="launchplane-merge-train-stack-collapse.json"
stack_collapse_url="${SERVICE_ORIGIN}/v1/work-graph/merge-train"
stack_collapse_url="${stack_collapse_url}/stack-collapse/run-once"
status_code="$(curl -sS \
-o "$response_file" \
-w '%{http_code}' \
-X POST \
-H "Authorization: Bearer ${oidc_token}" \
-H 'Content-Type: application/json' \
--data "$request_payload" \
"$stack_collapse_url")"
if [ "$status_code" != "202" ]; then
cat "$response_file" >&2
echo "Merge-train stack-collapse phase failed" \
"with HTTP ${status_code}." >&2
exit 1
fi
jq . "$response_file"
echo "response_file=${response_file}" >> "$GITHUB_OUTPUT"
{
echo
echo '## Launchplane merge train stack collapse'
echo
echo "- Mode: $(jq -r '.result.mode' "$response_file")"
stack_record="$(
jq -r '.records.merge_train_stack_collapse_plan_record_id // ""' \
"$response_file"
)"
candidate_record="$(
jq -r '.records.merge_train_batch_candidate_record_id // ""' \
"$response_file"
)"
stack_status="$(
jq -r '.result.stack_collapse_plan.status // ""' \
"$response_file"
)"
candidate_status="$(
jq -r '.result.candidate.status // ""' "$response_file"
)"
echo "- Stack-collapse record: ${stack_record}"
echo "- Stack status: ${stack_status}"
echo "- Candidate record: ${candidate_record}"
echo "- Candidate status: ${candidate_status}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Run one batch-landing phase
id: batch_landing
if: >-
steps.admission.outputs.admission_status == 'admitted' &&
env.MERGE_TRAIN_BATCH_LANDING_MODE != 'none'
shell: bash
env:
SERVICE_ORIGIN: ${{ steps.admission.outputs.service_origin }}
SERVICE_AUDIENCE: ${{ steps.admission.outputs.service_audience }}
run: |
set -euo pipefail
case "$MERGE_TRAIN_BATCH_LANDING_MODE" in
plan|land) ;;
*)
echo "Unsupported batch landing mode:" \
"${MERGE_TRAIN_BATCH_LANDING_MODE}" >&2
exit 1
;;
esac
if [ "$MERGE_TRAIN_BATCH_LANDING_MODE" = "plan" ] && \
[ -z "${MERGE_TRAIN_BATCH_LANDING_CANDIDATE_RECORD_ID}" ]; then
echo \
"batch_landing_candidate_record_id is required for plan mode." \
>&2
exit 1
fi
if [ "$MERGE_TRAIN_BATCH_LANDING_MODE" = "land" ] && \
[ -z "${MERGE_TRAIN_BATCH_LANDING_PLAN_RECORD_ID}" ]; then
echo \
"batch_landing_plan_record_id is required for land mode." \
>&2
exit 1
fi
oidc_token="$({
curl -fsSL \
-H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${SERVICE_AUDIENCE}" \
| jq -r '.value'
})"
request_payload="$({
jq -n \
--arg repository "$MERGE_TRAIN_REPOSITORY" \
--arg base_branch "$MERGE_TRAIN_BASE_BRANCH" \
--arg mode "$MERGE_TRAIN_BATCH_LANDING_MODE" \
--arg candidate_record_id \
"$MERGE_TRAIN_BATCH_LANDING_CANDIDATE_RECORD_ID" \
--arg landing_plan_record_id \
"$MERGE_TRAIN_BATCH_LANDING_PLAN_RECORD_ID" \
'{
schema_version: 1,
repository: $repository,
base_branch: $base_branch,
mode: $mode,
candidate_record_id: $candidate_record_id,
landing_plan_record_id: $landing_plan_record_id
}'
})"
response_file="launchplane-merge-train-batch-landing.json"
batch_landing_url="${SERVICE_ORIGIN}/v1/work-graph/merge-train"
batch_landing_url="${batch_landing_url}/batch-landing/run-once"
status_code="$(curl -sS \
-o "$response_file" \
-w '%{http_code}' \
-X POST \
-H "Authorization: Bearer ${oidc_token}" \
-H 'Content-Type: application/json' \
--data "$request_payload" \
"$batch_landing_url")"
if [ "$status_code" != "202" ]; then
cat "$response_file" >&2
echo "Merge-train batch-landing phase failed" \
"with HTTP ${status_code}." >&2
exit 1
fi
jq . "$response_file"
echo "response_file=${response_file}" >> "$GITHUB_OUTPUT"
{
echo
echo '## Launchplane merge train batch landing'
echo
echo "- Mode: $(jq -r '.result.mode' "$response_file")"
plan_record="$(
jq -r '.records.merge_train_batch_landing_plan_record_id' \
"$response_file"
)"
entry_statuses="$(
jq -r '[.result.landing_plan.entries[].status] | join(",")' \
"$response_file"
)"
echo "- Landing-plan record: ${plan_record}"
echo "- Entry statuses: ${entry_statuses}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Post manual phase PR feedback
if: >-
steps.admission.outputs.admission_status == 'admitted' &&
env.MERGE_TRAIN_RUNNER_MODE == 'level1' &&
(steps.batch_candidate.outputs.response_file != '' ||
steps.stack_collapse.outputs.response_file != '' ||
steps.batch_landing.outputs.response_file != '')
shell: bash
env:
SERVICE_ORIGIN: ${{ steps.admission.outputs.service_origin }}
SERVICE_AUDIENCE: ${{ steps.admission.outputs.service_audience }}
BATCH_CANDIDATE_RESPONSE: >-
${{ steps.batch_candidate.outputs.response_file }}
STACK_COLLAPSE_RESPONSE: >-
${{ steps.stack_collapse.outputs.response_file }}
BATCH_LANDING_RESPONSE: >-
${{ steps.batch_landing.outputs.response_file }}
run: |
set -euo pipefail
phase=""
response_file=""
if [ -n "${BATCH_CANDIDATE_RESPONSE}" ]; then
phase="batch-candidate"
response_file="${BATCH_CANDIDATE_RESPONSE}"
elif [ -n "${STACK_COLLAPSE_RESPONSE}" ]; then
phase="stack-collapse"
response_file="${STACK_COLLAPSE_RESPONSE}"
elif [ -n "${BATCH_LANDING_RESPONSE}" ]; then
phase="batch-landing"
response_file="${BATCH_LANDING_RESPONSE}"
fi
if [ -z "$phase" ] || [ -z "$response_file" ]; then
echo "No manual phase response file found."
exit 0
fi
feedback_payloads="launchplane-merge-train-${phase}-feedback.json"
python3 scripts/merge_train_controller_feedback.py \
--response-file "$response_file" \
--phase "$phase" \
--source "workflow:merge-train-runner:${phase}" \
> "$feedback_payloads"
feedback_count="$(jq 'length' "$feedback_payloads")"
if [ "$feedback_count" -gt 0 ]; then
oidc_token="$({
curl -fsSL \
-H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${SERVICE_AUDIENCE}" \
| jq -r '.value'
})"
feedback_url="${SERVICE_ORIGIN}/v1/work-graph/merge-train/pr-feedback"
feedback_index=0
while IFS= read -r feedback_payload; do
feedback_index="$((feedback_index + 1))"
feedback_response="launchplane-manual-phase-feedback-${feedback_index}.json"
feedback_payload_digest="$(
printf '%s' "$feedback_payload" | sha256sum | cut -d' ' -f1
)"
feedback_idempotency_key="merge-train-feedback:${phase}"
feedback_idempotency_key="${feedback_idempotency_key}:${GITHUB_RUN_ID}"
feedback_idempotency_key="${feedback_idempotency_key}:${feedback_payload_digest}"
feedback_status="$(curl -sS \
-o "$feedback_response" \
-w '%{http_code}' \
-X POST \
-H "Authorization: Bearer ${oidc_token}" \
-H "Idempotency-Key: ${feedback_idempotency_key}" \
-H 'Content-Type: application/json' \
--data "$feedback_payload" \
"$feedback_url")"
if [ "$feedback_status" != "202" ]; then
cat "$feedback_response" >&2
echo "Merge-train manual phase feedback failed" \
"with HTTP ${feedback_status}." >&2
exit 1
fi
jq . "$feedback_response"
done < <(jq -c '.[]' "$feedback_payloads")
fi
{
echo
echo '## Launchplane merge train manual phase feedback'
echo
echo "- Phase: ${phase}"
echo "- Feedback payloads: ${feedback_count}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Record deferred decision
if: steps.admission.outputs.admission_status != 'admitted'
shell: bash
run: |
set -euo pipefail
next_allowed_at="${{ steps.admission.outputs.next_allowed_at }}"
reason_code="${{ steps.admission.outputs.reason_code }}"
echo \
"Merge train deferred until ${next_allowed_at}" \
"(${reason_code})."