Merge Train Runner #586
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: 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})." |