Merge Train Runner #2341
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- | |
| 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: "" | |
| mutate: | |
| description: >- | |
| Apply one worker mutation when admitted. Scheduled runs use the | |
| active merge-train policy target's scheduler.mutate value instead. | |
| type: boolean | |
| required: false | |
| default: false | |
| runner_mode: | |
| description: >- | |
| Which admitted scheduler entrypoint to call: level1 or controller. | |
| Scheduled runs use the active merge-train policy target's | |
| scheduler.runner_mode value 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: | |
| runs-on: | |
| - self-hosted | |
| - ${{ vars.LAUNCHPLANE_RUNNER_LABEL }} | |
| env: | |
| LAUNCHPLANE_URL: ${{ vars.LAUNCHPLANE_PUBLIC_URL }} | |
| 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: Resolve manual merge-train target | |
| if: github.event_name != 'schedule' | |
| shell: bash | |
| env: | |
| REQUESTED_REPOSITORY: ${{ inputs.repository }} | |
| REQUESTED_BASE_BRANCH: ${{ inputs.base_branch }} | |
| REQUESTED_MUTATE: ${{ inputs.mutate && 'true' || 'false' }} | |
| REQUESTED_RUNNER_MODE: ${{ inputs.runner_mode || 'level1' }} | |
| run: | | |
| set -euo pipefail | |
| reject_multiline() { | |
| case "$1" in | |
| *$'\n'*|*$'\r'*) | |
| echo "$2 must be a single line." >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| } | |
| reject_multiline "$REQUESTED_REPOSITORY" repository | |
| reject_multiline "$REQUESTED_BASE_BRANCH" base_branch | |
| reject_multiline "$REQUESTED_MUTATE" mutate | |
| reject_multiline "$REQUESTED_RUNNER_MODE" runner_mode | |
| { | |
| echo "MERGE_TRAIN_REPOSITORY=${REQUESTED_REPOSITORY}" | |
| echo "MERGE_TRAIN_BASE_BRANCH=${REQUESTED_BASE_BRANCH}" | |
| echo "MERGE_TRAIN_MUTATE=${REQUESTED_MUTATE}" | |
| echo "MERGE_TRAIN_RUNNER_MODE=${REQUESTED_RUNNER_MODE}" | |
| echo "MERGE_TRAIN_SCHEDULED_TARGET=manual" | |
| } >> "$GITHUB_ENV" | |
| - name: Prepare scheduled merge-train target request | |
| id: scheduled_target_request | |
| if: github.event_name == 'schedule' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| : "${LAUNCHPLANE_URL:?Missing LAUNCHPLANE_PUBLIC_URL variable}" | |
| 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 | |
| })" | |
| targets_file="launchplane-merge-train-policy-targets.json" | |
| { | |
| echo "service_audience=${service_audience}" | |
| echo "response_file=${targets_file}" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Read scheduled merge-train targets | |
| if: github.event_name == 'schedule' | |
| id: scheduled_target_read | |
| uses: cbusillo/launchplane/.github/actions/launchplane-request@adcf937c6aef14e02478724040852d1d2a82a850 # main | |
| with: | |
| launchplane-url: ${{ env.LAUNCHPLANE_URL }} | |
| audience: ${{ steps.scheduled_target_request.outputs.service_audience }} | |
| method: GET | |
| route-path: /v1/work-graph/merge-train/policy-targets | |
| fail-result-paths: "" | |
| response-output-file: ${{ steps.scheduled_target_request.outputs.response_file }} | |
| log-response-body: "false" | |
| - name: Resolve scheduled merge-train target | |
| if: github.event_name == 'schedule' | |
| shell: bash | |
| env: | |
| TARGETS_FILE: ${{ steps.scheduled_target_request.outputs.response_file }} | |
| STATUS_CODE: ${{ steps.scheduled_target_read.outputs.status-code }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$STATUS_CODE" != "200" ]; then | |
| cat "$TARGETS_FILE" >&2 | |
| echo "Merge-train policy target lookup failed with HTTP ${STATUS_CODE}." >&2 | |
| exit 1 | |
| fi | |
| jq . "$TARGETS_FILE" | |
| scheduled_targets="$(jq -c '[.targets[] | select(.scheduler.enabled == true)]' "$TARGETS_FILE")" | |
| scheduled_target_count="$(jq 'length' <<< "$scheduled_targets")" | |
| if [ "$scheduled_target_count" -eq 0 ]; then | |
| echo "MERGE_TRAIN_SCHEDULED_TARGET=disabled" >> "$GITHUB_ENV" | |
| { | |
| echo '## Launchplane merge train scheduler' | |
| echo | |
| echo '- Status: disabled' | |
| echo '- Reason: no DB-backed scheduler target is enabled' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| if [ "$scheduled_target_count" -ne 1 ]; then | |
| echo "Expected exactly one enabled merge-train scheduler target;" \ | |
| "found ${scheduled_target_count}." >&2 | |
| jq -r '.[] | "- " + .policy_key' <<< "$scheduled_targets" >&2 | |
| exit 1 | |
| fi | |
| repository="$(jq -r '.[0].repository' <<< "$scheduled_targets")" | |
| base_branch="$(jq -r '.[0].base_branch' <<< "$scheduled_targets")" | |
| runner_mode="$(jq -r '.[0].scheduler.runner_mode' <<< "$scheduled_targets")" | |
| mutate="$(jq -r '.[0].scheduler.mutate | tostring' <<< "$scheduled_targets")" | |
| reject_multiline() { | |
| case "$1" in | |
| *$'\n'*|*$'\r'*) | |
| echo "$2 must be a single line." >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| } | |
| reject_multiline "$repository" repository | |
| reject_multiline "$base_branch" base_branch | |
| reject_multiline "$runner_mode" runner_mode | |
| reject_multiline "$mutate" mutate | |
| { | |
| echo "MERGE_TRAIN_REPOSITORY=${repository}" | |
| echo "MERGE_TRAIN_BASE_BRANCH=${base_branch}" | |
| echo "MERGE_TRAIN_RUNNER_MODE=${runner_mode}" | |
| echo "MERGE_TRAIN_MUTATE=${mutate}" | |
| echo "MERGE_TRAIN_SCHEDULED_TARGET=enabled" | |
| } >> "$GITHUB_ENV" | |
| { | |
| echo '## Launchplane merge train scheduler' | |
| echo | |
| echo '- Status: enabled' | |
| echo "- Repository: ${repository}" | |
| echo "- Base branch: ${base_branch}" | |
| echo "- Runner mode: ${runner_mode}" | |
| echo "- Mutate: ${mutate}" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Prepare merge-train admission request | |
| id: admission_request | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| : "${LAUNCHPLANE_URL:?Missing LAUNCHPLANE_PUBLIC_URL variable}" | |
| if [ "${MERGE_TRAIN_SCHEDULED_TARGET}" = "disabled" ]; then | |
| echo "should_request=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| : "${MERGE_TRAIN_REPOSITORY:?Set repository input or enable one policy scheduler target}" | |
| : "${MERGE_TRAIN_BASE_BRANCH:?Missing base branch}" | |
| 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 | |
| })" | |
| response_file="launchplane-merge-train-admission.json" | |
| { | |
| echo "should_request=true" | |
| echo "service_audience=${service_audience}" | |
| echo "route_path=/v1/work-graph/merge-train/admission?${query_string}" | |
| echo "response_file=${response_file}" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Read merge-train admission | |
| if: steps.admission_request.outputs.should_request == 'true' | |
| id: admission_read | |
| uses: cbusillo/launchplane/.github/actions/launchplane-request@adcf937c6aef14e02478724040852d1d2a82a850 # main | |
| with: | |
| launchplane-url: ${{ env.LAUNCHPLANE_URL }} | |
| audience: ${{ steps.admission_request.outputs.service_audience }} | |
| method: GET | |
| route-path: ${{ steps.admission_request.outputs.route_path }} | |
| fail-result-paths: "" | |
| response-output-file: ${{ steps.admission_request.outputs.response_file }} | |
| log-response-body: "false" | |
| - name: Request admission decision | |
| id: admission | |
| shell: bash | |
| env: | |
| RESPONSE_FILE: ${{ steps.admission_request.outputs.response_file }} | |
| SERVICE_AUDIENCE: ${{ steps.admission_request.outputs.service_audience }} | |
| SHOULD_REQUEST: ${{ steps.admission_request.outputs.should_request }} | |
| STATUS_CODE: ${{ steps.admission_read.outputs.status-code }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$SHOULD_REQUEST" != "true" ]; then | |
| { | |
| echo "admission_status=deferred" | |
| echo "reason_code=no_enabled_scheduler_target" | |
| echo "next_allowed_at=" | |
| } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| 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_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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Prepare merge-train run-once request | |
| id: level1_request | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_RUNNER_MODE == 'level1' | |
| shell: bash | |
| 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." | |
| echo "should_request=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| request_payload_file="launchplane-merge-train-run-once-request.json" | |
| response_file="launchplane-merge-train-run-once.json" | |
| 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 | |
| }' > "$request_payload_file" | |
| payload_digest="$(sha256sum "$request_payload_file" | cut -d' ' -f1)" | |
| idempotency_key="merge-train-run-once" | |
| idempotency_key="${idempotency_key}:${GITHUB_RUN_ID}" | |
| idempotency_key="${idempotency_key}:${payload_digest}" | |
| { | |
| echo "should_request=true" | |
| echo "idempotency_key=${idempotency_key}" | |
| echo "payload_file=${request_payload_file}" | |
| echo "response_file=${response_file}" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Send merge-train run-once request | |
| if: steps.level1_request.outputs.should_request == 'true' | |
| id: level1_run | |
| uses: cbusillo/launchplane/.github/actions/launchplane-request@adcf937c6aef14e02478724040852d1d2a82a850 # main | |
| with: | |
| launchplane-url: ${{ env.LAUNCHPLANE_URL }} | |
| audience: ${{ steps.admission.outputs.service_audience }} | |
| method: POST | |
| route-path: /v1/work-graph/merge-train/run-once | |
| payload-file: ${{ steps.level1_request.outputs.payload_file }} | |
| idempotency-key: ${{ steps.level1_request.outputs.idempotency_key }} | |
| fail-result-paths: "" | |
| response-output-file: ${{ steps.level1_request.outputs.response_file }} | |
| log-response-body: "false" | |
| - name: Summarize merge-train run-once response | |
| if: steps.level1_request.outputs.should_request == 'true' | |
| shell: bash | |
| env: | |
| RESPONSE_FILE: ${{ steps.level1_request.outputs.response_file }} | |
| STATUS_CODE: ${{ steps.level1_run.outputs.status-code }} | |
| run: | | |
| set -euo pipefail | |
| 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: Prepare merge-train controller request | |
| id: controller_request | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_RUNNER_MODE == 'controller' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| request_payload_file="launchplane-merge-train-controller-run-once-request.json" | |
| response_file="launchplane-merge-train-controller-run-once.json" | |
| 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 | |
| }' > "$request_payload_file" | |
| payload_digest="$(sha256sum "$request_payload_file" | cut -d' ' -f1)" | |
| idempotency_key="merge-train-controller-run-once" | |
| idempotency_key="${idempotency_key}:${GITHUB_RUN_ID}" | |
| idempotency_key="${idempotency_key}:${payload_digest}" | |
| { | |
| echo "idempotency_key=${idempotency_key}" | |
| echo "payload_file=${request_payload_file}" | |
| echo "response_file=${response_file}" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Send merge-train controller request | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_RUNNER_MODE == 'controller' | |
| id: controller_run | |
| uses: cbusillo/launchplane/.github/actions/launchplane-request@adcf937c6aef14e02478724040852d1d2a82a850 # main | |
| with: | |
| launchplane-url: ${{ env.LAUNCHPLANE_URL }} | |
| audience: ${{ steps.admission.outputs.service_audience }} | |
| method: POST | |
| route-path: /v1/work-graph/merge-train/controller/run-once | |
| payload-file: ${{ steps.controller_request.outputs.payload_file }} | |
| idempotency-key: ${{ steps.controller_request.outputs.idempotency_key }} | |
| fail-result-paths: "" | |
| response-output-file: ${{ steps.controller_request.outputs.response_file }} | |
| log-response-body: "false" | |
| - name: Summarize merge-train controller response | |
| id: controller_summary | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_RUNNER_MODE == 'controller' | |
| shell: bash | |
| env: | |
| RESPONSE_FILE: ${{ steps.controller_request.outputs.response_file }} | |
| STATUS_CODE: ${{ steps.controller_run.outputs.status-code }} | |
| run: | | |
| set -euo pipefail | |
| 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 | |
| feedback_response="launchplane-merge-train-feedback-responses.json" | |
| feedback_idempotency_key_prefix="merge-train-feedback:controller" | |
| feedback_idempotency_key_prefix="${feedback_idempotency_key_prefix}:${GITHUB_RUN_ID}" | |
| { | |
| echo "feedback_count=${feedback_count}" | |
| echo "feedback_idempotency_key_prefix=${feedback_idempotency_key_prefix}" | |
| echo "feedback_payloads=${feedback_payloads}" | |
| echo "feedback_response=${feedback_response}" | |
| } >> "$GITHUB_OUTPUT" | |
| { | |
| 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: Send merge-train controller PR feedback | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_RUNNER_MODE == 'controller' && | |
| steps.controller_summary.outputs.feedback_count != '0' | |
| uses: cbusillo/launchplane/.github/actions/launchplane-request@adcf937c6aef14e02478724040852d1d2a82a850 # main | |
| with: | |
| launchplane-url: ${{ env.LAUNCHPLANE_URL }} | |
| audience: ${{ steps.admission.outputs.service_audience }} | |
| method: POST | |
| route-path: /v1/work-graph/merge-train/pr-feedback | |
| payload-list-file: ${{ steps.controller_summary.outputs.feedback_payloads }} | |
| idempotency-key-prefix: >- | |
| ${{ steps.controller_summary.outputs.feedback_idempotency_key_prefix }} | |
| fail-result-paths: "" | |
| response-output-file: ${{ steps.controller_summary.outputs.feedback_response }} | |
| log-response-body: "false" | |
| - name: Prepare batch-candidate phase request | |
| id: batch_candidate_request | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_BATCH_CANDIDATE_MODE != 'none' | |
| shell: bash | |
| 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 | |
| request_payload_file="launchplane-merge-train-batch-candidate-request.json" | |
| response_file="launchplane-merge-train-batch-candidate.json" | |
| 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 | |
| }' > "$request_payload_file" | |
| payload_digest="$(sha256sum "$request_payload_file" | cut -d' ' -f1)" | |
| idempotency_key="merge-train-batch-candidate-run-once" | |
| idempotency_key="${idempotency_key}:${GITHUB_RUN_ID}" | |
| idempotency_key="${idempotency_key}:${payload_digest}" | |
| { | |
| echo "idempotency_key=${idempotency_key}" | |
| echo "payload_file=${request_payload_file}" | |
| echo "response_file=${response_file}" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Send batch-candidate phase request | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_BATCH_CANDIDATE_MODE != 'none' | |
| id: batch_candidate_run | |
| uses: cbusillo/launchplane/.github/actions/launchplane-request@adcf937c6aef14e02478724040852d1d2a82a850 # main | |
| with: | |
| launchplane-url: ${{ env.LAUNCHPLANE_URL }} | |
| audience: ${{ steps.admission.outputs.service_audience }} | |
| method: POST | |
| route-path: /v1/work-graph/merge-train/batch-candidate/run-once | |
| payload-file: ${{ steps.batch_candidate_request.outputs.payload_file }} | |
| idempotency-key: ${{ steps.batch_candidate_request.outputs.idempotency_key }} | |
| fail-result-paths: "" | |
| response-output-file: ${{ steps.batch_candidate_request.outputs.response_file }} | |
| log-response-body: "false" | |
| - name: Summarize batch-candidate phase response | |
| id: batch_candidate | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_BATCH_CANDIDATE_MODE != 'none' | |
| shell: bash | |
| env: | |
| RESPONSE_FILE: ${{ steps.batch_candidate_request.outputs.response_file }} | |
| STATUS_CODE: ${{ steps.batch_candidate_run.outputs.status-code }} | |
| run: | | |
| set -euo pipefail | |
| 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: Prepare stack-collapse phase request | |
| id: stack_collapse_request | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_STACK_COLLAPSE_MODE != 'none' | |
| shell: bash | |
| 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 | |
| request_payload_file="launchplane-merge-train-stack-collapse-request.json" | |
| response_file="launchplane-merge-train-stack-collapse.json" | |
| 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 | |
| }' > "$request_payload_file" | |
| payload_digest="$(sha256sum "$request_payload_file" | cut -d' ' -f1)" | |
| idempotency_key="merge-train-stack-collapse-run-once" | |
| idempotency_key="${idempotency_key}:${GITHUB_RUN_ID}" | |
| idempotency_key="${idempotency_key}:${payload_digest}" | |
| { | |
| echo "idempotency_key=${idempotency_key}" | |
| echo "payload_file=${request_payload_file}" | |
| echo "response_file=${response_file}" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Send stack-collapse phase request | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_STACK_COLLAPSE_MODE != 'none' | |
| id: stack_collapse_run | |
| uses: cbusillo/launchplane/.github/actions/launchplane-request@adcf937c6aef14e02478724040852d1d2a82a850 # main | |
| with: | |
| launchplane-url: ${{ env.LAUNCHPLANE_URL }} | |
| audience: ${{ steps.admission.outputs.service_audience }} | |
| method: POST | |
| route-path: /v1/work-graph/merge-train/stack-collapse/run-once | |
| payload-file: ${{ steps.stack_collapse_request.outputs.payload_file }} | |
| idempotency-key: ${{ steps.stack_collapse_request.outputs.idempotency_key }} | |
| fail-result-paths: "" | |
| response-output-file: ${{ steps.stack_collapse_request.outputs.response_file }} | |
| log-response-body: "false" | |
| - name: Summarize stack-collapse phase response | |
| id: stack_collapse | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_STACK_COLLAPSE_MODE != 'none' | |
| shell: bash | |
| env: | |
| RESPONSE_FILE: ${{ steps.stack_collapse_request.outputs.response_file }} | |
| STATUS_CODE: ${{ steps.stack_collapse_run.outputs.status-code }} | |
| run: | | |
| set -euo pipefail | |
| 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: Prepare batch-landing phase request | |
| id: batch_landing_request | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_BATCH_LANDING_MODE != 'none' | |
| shell: bash | |
| 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 | |
| request_payload_file="launchplane-merge-train-batch-landing-request.json" | |
| response_file="launchplane-merge-train-batch-landing.json" | |
| 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 | |
| }' > "$request_payload_file" | |
| payload_digest="$(sha256sum "$request_payload_file" | cut -d' ' -f1)" | |
| idempotency_key="merge-train-batch-landing-run-once" | |
| idempotency_key="${idempotency_key}:${GITHUB_RUN_ID}" | |
| idempotency_key="${idempotency_key}:${payload_digest}" | |
| { | |
| echo "idempotency_key=${idempotency_key}" | |
| echo "payload_file=${request_payload_file}" | |
| echo "response_file=${response_file}" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Send batch-landing phase request | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_BATCH_LANDING_MODE != 'none' | |
| id: batch_landing_run | |
| uses: cbusillo/launchplane/.github/actions/launchplane-request@adcf937c6aef14e02478724040852d1d2a82a850 # main | |
| with: | |
| launchplane-url: ${{ env.LAUNCHPLANE_URL }} | |
| audience: ${{ steps.admission.outputs.service_audience }} | |
| method: POST | |
| route-path: /v1/work-graph/merge-train/batch-landing/run-once | |
| payload-file: ${{ steps.batch_landing_request.outputs.payload_file }} | |
| idempotency-key: ${{ steps.batch_landing_request.outputs.idempotency_key }} | |
| fail-result-paths: "" | |
| response-output-file: ${{ steps.batch_landing_request.outputs.response_file }} | |
| log-response-body: "false" | |
| - name: Summarize batch-landing phase response | |
| id: batch_landing | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_BATCH_LANDING_MODE != 'none' | |
| shell: bash | |
| env: | |
| RESPONSE_FILE: ${{ steps.batch_landing_request.outputs.response_file }} | |
| STATUS_CODE: ${{ steps.batch_landing_run.outputs.status-code }} | |
| run: | | |
| set -euo pipefail | |
| 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 | |
| id: manual_phase_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: | |
| 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 [ "$MERGE_TRAIN_MUTATE" != "true" ]; then | |
| feedback_count=0 | |
| fi | |
| feedback_response="launchplane-merge-train-${phase}-feedback-responses.json" | |
| feedback_idempotency_key_prefix="merge-train-feedback:${phase}" | |
| feedback_idempotency_key_prefix="${feedback_idempotency_key_prefix}:${GITHUB_RUN_ID}" | |
| { | |
| echo "feedback_count=${feedback_count}" | |
| echo "feedback_idempotency_key_prefix=${feedback_idempotency_key_prefix}" | |
| echo "feedback_payloads=${feedback_payloads}" | |
| echo "feedback_response=${feedback_response}" | |
| } >> "$GITHUB_OUTPUT" | |
| { | |
| echo | |
| echo '## Launchplane merge train manual phase feedback' | |
| echo | |
| echo "- Phase: ${phase}" | |
| echo "- Feedback payloads: ${feedback_count}" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Send manual phase PR feedback | |
| if: >- | |
| steps.admission.outputs.admission_status == 'admitted' && | |
| env.MERGE_TRAIN_RUNNER_MODE == 'level1' && | |
| steps.manual_phase_feedback.outputs.feedback_count != '0' | |
| uses: cbusillo/launchplane/.github/actions/launchplane-request@adcf937c6aef14e02478724040852d1d2a82a850 # main | |
| with: | |
| launchplane-url: ${{ env.LAUNCHPLANE_URL }} | |
| audience: ${{ steps.admission.outputs.service_audience }} | |
| method: POST | |
| route-path: /v1/work-graph/merge-train/pr-feedback | |
| payload-list-file: ${{ steps.manual_phase_feedback.outputs.feedback_payloads }} | |
| idempotency-key-prefix: >- | |
| ${{ steps.manual_phase_feedback.outputs.feedback_idempotency_key_prefix }} | |
| fail-result-paths: "" | |
| response-output-file: ${{ steps.manual_phase_feedback.outputs.feedback_response }} | |
| log-response-body: "false" | |
| - name: Record deferred decision | |
| if: steps.admission.outputs.admission_status != 'admitted' | |
| shell: bash | |
| env: | |
| NEXT_ALLOWED_AT: ${{ steps.admission.outputs.next_allowed_at }} | |
| REASON_CODE: ${{ steps.admission.outputs.reason_code }} | |
| run: | | |
| set -euo pipefail | |
| echo \ | |
| "Merge train deferred until ${NEXT_ALLOWED_AT}" \ | |
| "(${REASON_CODE})." |