From 32d257d50a540ecc369736ac77a17ea7964a6a98 Mon Sep 17 00:00:00 2001 From: Ryan Baxter Date: Wed, 7 Oct 2026 09:20:03 -0400 Subject: [PATCH] Create an action we can reuse to wait for a workflow to finish --- .github/actions/dispatch-and-wait/README.md | 49 +++++++++ .github/actions/dispatch-and-wait/action.yml | 104 ++++++++++++++++++ .../action.yml | 28 ++--- .../create-commercial-release-branch.yml | 32 ++---- .../create-hotfix-release-branch.yml | 39 +++---- .../workflows/create-oss-release-branch.yml | 39 +++---- .github/workflows/create-release-branches.yml | 55 +++++---- README.md | 1 + 8 files changed, 230 insertions(+), 117 deletions(-) create mode 100644 .github/actions/dispatch-and-wait/README.md create mode 100644 .github/actions/dispatch-and-wait/action.yml diff --git a/.github/actions/dispatch-and-wait/README.md b/.github/actions/dispatch-and-wait/README.md new file mode 100644 index 0000000..68fa814 --- /dev/null +++ b/.github/actions/dispatch-and-wait/README.md @@ -0,0 +1,49 @@ +# dispatch-and-wait + +Dispatches a `workflow_dispatch` workflow, waits for the run to complete, and fails the step unless the run's conclusion is `success`. + +Used wherever a workflow starts another one and depends on its result: `release-train-join` from the three branch-creation workflows, `release-train-ready` from `spring-release-train-project-ready`, and each per-project run from `create-release-branches`. + +## Inputs + +| Name | Required | Default | Description | +|------|----------|---------|-------------| +| `repo` | yes | — | Full repository path of the workflow to run (e.g. `spring-cloud/spring-cloud-config-commercial`) | +| `workflow` | yes | — | Workflow file name (e.g. `release-train-join.yml`) | +| `ref` | yes | — | Branch or tag to run the workflow on | +| `inputs` | no | — | Workflow inputs, one `key=value` per line. Everything after the first `=` is the value, passed as a raw string, so values may contain spaces. Blank lines are ignored. | +| `summary-title` | no | — | When set, appends `: succeeded\|FAILED () - ` to the job's step summary | +| `interval` | no | `10` | Seconds between status checks | +| `token` | yes | — | GitHub token allowed to dispatch and read workflow runs in `repo` | + +## Outputs + +| Name | Description | +|------|-------------| +| `run-url` | URL of the dispatched run | +| `conclusion` | Conclusion of the run (`success`, `failure`, `cancelled`, ...) | + +## Usage + +```yaml +- name: Checkout + uses: actions/checkout@v4 + +- name: Trigger release-train-join workflow and wait + uses: ./.github/actions/dispatch-and-wait + with: + repo: spring-cloud/spring-cloud-config-commercial + workflow: release-train-join.yml + ref: release/5.0.4 + inputs: | + deployment-destination=Spring Enterprise + release-train=2026.1 + release-train-repository=spring-io/release-train + token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} +``` + +## Notes + +- **The run's conclusion decides the result, not `gh run watch`'s exit status.** Right after dispatch, `gh run watch` can exit non-zero before the run has any jobs, even though the run goes on to succeed. The watch is only used to wait: if it returns before the run is `completed`, the action checks the status and watches again. +- The watch's error output is kept in the log rather than discarded, so an early exit is visible. +- The run id is read from the URL `gh workflow run` prints. If that output isn't a run URL, the step fails rather than waiting on nothing. diff --git a/.github/actions/dispatch-and-wait/action.yml b/.github/actions/dispatch-and-wait/action.yml new file mode 100644 index 0000000..3ab80b5 --- /dev/null +++ b/.github/actions/dispatch-and-wait/action.yml @@ -0,0 +1,104 @@ +name: 'Dispatch and Wait' +description: > + Dispatches a workflow_dispatch workflow, waits for the run to complete, and fails unless + the run's conclusion is success. + +inputs: + repo: + description: 'Full repository path of the workflow to run (e.g. spring-cloud/spring-cloud-config-commercial)' + required: true + workflow: + description: 'Workflow file name (e.g. release-train-join.yml)' + required: true + ref: + description: 'Branch or tag to run the workflow on' + required: true + inputs: + description: > + Workflow inputs, one key=value per line. Everything after the first = is the value, + passed as a raw string, so values may contain spaces or further = signs. Blank lines + are ignored. + required: false + default: '' + summary-title: + description: > + When set, a line ": succeeded|FAILED () - " is + appended to the job's step summary. + required: false + default: '' + interval: + description: 'Seconds between status checks' + required: false + default: '10' + token: + description: 'GitHub token allowed to dispatch and read workflow runs in repo' + required: true + +outputs: + run-url: + description: 'URL of the dispatched run' + value: ${{ steps.run.outputs.run-url }} + conclusion: + description: 'Conclusion of the run (success, failure, cancelled, ...)' + value: ${{ steps.run.outputs.conclusion }} + +runs: + using: composite + steps: + - name: Dispatch ${{ inputs.workflow }} and wait + id: run + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + REPO: ${{ inputs.repo }} + WORKFLOW: ${{ inputs.workflow }} + REF: ${{ inputs.ref }} + WORKFLOW_INPUTS: ${{ inputs.inputs }} + SUMMARY_TITLE: ${{ inputs.summary-title }} + INTERVAL: ${{ inputs.interval }} + run: | + set -euo pipefail + + args=() + while IFS= read -r line; do + line="${line%$'\r'}" + [[ -z "${line//[[:space:]]/}" ]] && continue + if [[ "$line" != *=* ]]; then + echo "::error::Workflow input '${line}' is not key=value." + exit 1 + fi + args+=(-f "$line") + done <<< "$WORKFLOW_INPUTS" + + echo "Dispatching ${WORKFLOW} in ${REPO} on ${REF}..." + run_url=$(gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$REF" "${args[@]}") + run_id="${run_url##*/}" + if [[ ! "$run_id" =~ ^[0-9]+$ ]]; then + echo "::error::Could not read the run id from gh workflow run's output: '${run_url}'" + exit 1 + fi + echo "run-url=${run_url}" >> "$GITHUB_OUTPUT" + echo "Dispatched. Waiting for ${run_url} to complete." + + # gh run watch's exit status is not the run's result: right after dispatch it can + # exit non-zero before the run has any jobs, while the run goes on to succeed. So + # the watch is only used to wait, and the run's own conclusion decides the outcome, + # re-watching until the run has actually completed. + while :; do + gh run watch "$run_id" --repo "$REPO" --interval="$INTERVAL" > /dev/null \ + || echo "gh run watch exited early; checking the run's status." + status=$(gh run view "$run_id" --repo "$REPO" --json status --jq .status) || status="" + [[ "$status" == "completed" ]] && break + sleep "$INTERVAL" + done + conclusion=$(gh run view "$run_id" --repo "$REPO" --json conclusion --jq .conclusion) + echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" + + if [[ "$conclusion" == "success" ]]; then + echo "${WORKFLOW} succeeded: ${run_url}" + [[ -n "$SUMMARY_TITLE" ]] && echo "### ${SUMMARY_TITLE}: succeeded - ${run_url}" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + [[ -n "$SUMMARY_TITLE" ]] && echo "### ${SUMMARY_TITLE}: FAILED (${conclusion}) - ${run_url}" >> "$GITHUB_STEP_SUMMARY" + echo "::error::${WORKFLOW} in ${REPO} finished with conclusion '${conclusion}': ${run_url}" + exit 1 diff --git a/.github/actions/spring-release-train-project-ready/action.yml b/.github/actions/spring-release-train-project-ready/action.yml index 7412b64..59fe241 100644 --- a/.github/actions/spring-release-train-project-ready/action.yml +++ b/.github/actions/spring-release-train-project-ready/action.yml @@ -179,25 +179,15 @@ runs: fi - name: Trigger release-train-ready workflow - shell: bash - env: - GH_TOKEN: ${{ inputs.token }} - run: | - run_url=$(gh workflow run release-train-ready.yml \ - --repo spring-cloud/${{ inputs.project }} \ - --ref release/${{ steps.version.outputs.version }} \ - --field release-train=${{ inputs.spring-release-train-version }} \ - --field release-train-repository=spring-io/release-train) - echo "Dispatched workflow run. Waiting for $run_url to complete." - run_id=${run_url##*/} - watch_exit_code=0 - gh run watch $run_id --repo spring-cloud/${{ inputs.project }} --exit-status --interval=3 > /dev/null 2>&1 || watch_exit_code=$? - if [[ $watch_exit_code -eq 0 ]]; then - echo "Workflow run succeeded." - else - echo "Workflow run failed." - fi - exit $watch_exit_code + uses: ./.github/actions/dispatch-and-wait + with: + repo: spring-cloud/${{ inputs.project }} + workflow: release-train-ready.yml + ref: release/${{ steps.version.outputs.version }} + inputs: | + release-train=${{ inputs.spring-release-train-version }} + release-train-repository=spring-io/release-train + token: ${{ inputs.token }} # A hotfix release/ branch is registered in config/projects.json for its own # scheduled CI entry (create-hotfix-release-branch.yml); a GA release/ branch diff --git a/.github/workflows/create-commercial-release-branch.yml b/.github/workflows/create-commercial-release-branch.yml index 3ce007a..f218cfd 100644 --- a/.github/workflows/create-commercial-release-branch.yml +++ b/.github/workflows/create-commercial-release-branch.yml @@ -210,25 +210,13 @@ jobs: # evaluate falsy and skip the join rather than fail visibly. - name: Trigger release-train-join workflow and wait if: ${{ inputs['release-train'] != '' }} - env: - GH_TOKEN: ${{ inputs.token || secrets.token || secrets.GH_ACTIONS_REPO_TOKEN }} - run: | - commercial_project="${{ steps.setup.outputs.commercial-project }}" - release_branch="release/${{ steps.setup.outputs.release-version }}" - - run_url=$(gh workflow run release-train-join.yml \ - --repo "spring-cloud/${commercial_project}" \ - --ref "${release_branch}" \ - --field "deployment-destination=Spring Enterprise" \ - --field "release-train=${{ inputs.release-train }}" \ - --field "release-train-repository=spring-io/release-train") - echo "Dispatched workflow run. Waiting for $run_url to complete." - run_id=${run_url##*/} - watch_exit_code=0 - gh run watch $run_id --repo "spring-cloud/${commercial_project}" --exit-status --interval=3 > /dev/null 2>&1 || watch_exit_code=$? - if [[ $watch_exit_code -eq 0 ]]; then - echo "Workflow run succeeded." - else - echo "Workflow run failed." - fi - exit $watch_exit_code + uses: ./.github/actions/dispatch-and-wait + with: + repo: spring-cloud/${{ steps.setup.outputs.commercial-project }} + workflow: release-train-join.yml + ref: release/${{ steps.setup.outputs.release-version }} + inputs: | + deployment-destination=Spring Enterprise + release-train=${{ inputs['release-train'] }} + release-train-repository=spring-io/release-train + token: ${{ inputs.token || secrets.token || secrets.GH_ACTIONS_REPO_TOKEN }} diff --git a/.github/workflows/create-hotfix-release-branch.yml b/.github/workflows/create-hotfix-release-branch.yml index 167533f..c529af2 100644 --- a/.github/workflows/create-hotfix-release-branch.yml +++ b/.github/workflows/create-hotfix-release-branch.yml @@ -493,31 +493,24 @@ jobs: needs: [derive, ensure-workflows, create-milestone] runs-on: ubuntu-latest steps: + - name: Checkout + if: ${{ inputs.spring_release_train != '' }} + uses: actions/checkout@v4 + with: + ref: ${{ inputs.sha || github.sha }} + - name: Trigger release-train-join workflow and wait if: ${{ inputs.spring_release_train != '' }} - env: - GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} - shell: bash - run: | - commercial_project="${{ needs.derive.outputs.commercial_project }}" - release_branch="${{ needs.derive.outputs.commercial_branch }}" - echo "Triggering release-train-join.yml in spring-cloud/${commercial_project} on ${release_branch}..." - run_url=$(gh workflow run release-train-join.yml \ - --repo "spring-cloud/${commercial_project}" \ - --ref "${release_branch}" \ - --field "deployment-destination=Spring Enterprise" \ - --field "release-train=${{ inputs.spring_release_train }}" \ - --field "release-train-repository=spring-io/release-train") - echo "Dispatched workflow run. Waiting for $run_url to complete." - run_id=${run_url##*/} - watch_exit_code=0 - gh run watch $run_id --repo "spring-cloud/${commercial_project}" --exit-status --interval=3 > /dev/null 2>&1 || watch_exit_code=$? - if [[ $watch_exit_code -eq 0 ]]; then - echo "Workflow run succeeded." - else - echo "Workflow run failed." - fi - exit $watch_exit_code + uses: ./.github/actions/dispatch-and-wait + with: + repo: spring-cloud/${{ needs.derive.outputs.commercial_project }} + workflow: release-train-join.yml + ref: ${{ needs.derive.outputs.commercial_branch }} + inputs: | + deployment-destination=Spring Enterprise + release-train=${{ inputs.spring_release_train }} + release-train-repository=spring-io/release-train + token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} trigger-ci: needs: [derive, initialize, create-internal-release-branch, trigger-release-train-join] diff --git a/.github/workflows/create-oss-release-branch.yml b/.github/workflows/create-oss-release-branch.yml index 94d85b8..10b0589 100644 --- a/.github/workflows/create-oss-release-branch.yml +++ b/.github/workflows/create-oss-release-branch.yml @@ -621,31 +621,24 @@ jobs: needs: [derive, ensure-workflows, create-milestone] runs-on: ubuntu-latest steps: - - name: Trigger release-train-join workflow and wait + - name: Checkout if: ${{ inputs.spring_release_train != '' }} - env: - GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} - run: | - commercial_project="${{ needs.derive.outputs.commercial_project }}" - commercial_branch="${{ needs.derive.outputs.commercial_branch }}" + uses: actions/checkout@v4 + with: + ref: ${{ inputs.sha || github.sha }} - echo "Triggering release-train-join.yml in spring-cloud/${commercial_project} on ${commercial_branch}..." - run_url=$(gh workflow run release-train-join.yml \ - --repo "spring-cloud/${commercial_project}" \ - --ref "${commercial_branch}" \ - --field "deployment-destination=Maven Central" \ - --field "release-train=${{ inputs.spring_release_train }}" \ - --field "release-train-repository=spring-io/release-train") - echo "Dispatched workflow run. Waiting for $run_url to complete." - run_id=${run_url##*/} - watch_exit_code=0 - gh run watch $run_id --repo "spring-cloud/${commercial_project}" --exit-status --interval=3 > /dev/null 2>&1 || watch_exit_code=$? - if [[ $watch_exit_code -eq 0 ]]; then - echo "Workflow run succeeded." - else - echo "Workflow run failed." - fi - exit $watch_exit_code + - name: Trigger release-train-join workflow and wait + if: ${{ inputs.spring_release_train != '' }} + uses: ./.github/actions/dispatch-and-wait + with: + repo: spring-cloud/${{ needs.derive.outputs.commercial_project }} + workflow: release-train-join.yml + ref: ${{ needs.derive.outputs.commercial_branch }} + inputs: | + deployment-destination=Maven Central + release-train=${{ inputs.spring_release_train }} + release-train-repository=spring-io/release-train + token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} # Squash all [skip actions] initialization commits into a single commit and # push it (without [skip actions]) to trigger the CI pipeline on the newly diff --git a/.github/workflows/create-release-branches.yml b/.github/workflows/create-release-branches.yml index bc91785..311b706 100644 --- a/.github/workflows/create-release-branches.yml +++ b/.github/workflows/create-release-branches.yml @@ -81,7 +81,6 @@ jobs: matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} steps: - name: Checkout - if: ${{ inputs.mode == 'create-commercial-release-branch' }} uses: actions/checkout@v4 # The commercial workflow takes the branch to cut from as an input; the other two @@ -97,9 +96,9 @@ jobs: commercial: 'true' token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} - - name: Dispatch ${{ inputs.mode }} and wait + - name: Build the ${{ inputs.mode }} inputs + id: inputs env: - GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} MODE: ${{ inputs.mode }} PROJECT: ${{ matrix.project }} TRAIN: ${{ inputs.spring_cloud_release_train }} @@ -112,44 +111,40 @@ jobs: run: | set -euo pipefail - args=() + lines=() case "$MODE" in create-oss-release-branch) - args+=(-f "oss_repo=${PROJECT}" -f "spring_cloud_release_train=${TRAIN}" - -f "spring_release_train=${SPRING_TRAIN}") - [[ -n "$SHA" ]] && args+=(-f "sha=${SHA}") + lines+=("oss_repo=${PROJECT}" "spring_cloud_release_train=${TRAIN}" + "spring_release_train=${SPRING_TRAIN}") + [[ -n "$SHA" ]] && lines+=("sha=${SHA}") ;; create-hotfix-release-branch) - args+=(-f "oss_repo=${PROJECT}" -f "release_train_version=${TRAIN}" - -f "spring_release_train=${SPRING_TRAIN}" -f "use_tag=${USE_TAG}") - [[ -n "$SHA" ]] && args+=(-f "sha=${SHA}") + lines+=("oss_repo=${PROJECT}" "release_train_version=${TRAIN}" + "spring_release_train=${SPRING_TRAIN}" "use_tag=${USE_TAG}") + [[ -n "$SHA" ]] && lines+=("sha=${SHA}") ;; create-commercial-release-branch) if [[ "$SOURCE_STATUS" != "ok" ]]; then echo "::error::Could not resolve the source branch for ${PROJECT}-commercial: ${SOURCE_MESSAGE}" exit 1 fi - args+=(-f "project=${PROJECT}-commercial" -f "branch=${SOURCE_BRANCH}" - -f "release-train=${SPRING_TRAIN}") + lines+=("project=${PROJECT}-commercial" "branch=${SOURCE_BRANCH}" + "release-train=${SPRING_TRAIN}") ;; esac - echo "Dispatching ${MODE}.yml for ${PROJECT}..." - run_url=$(gh workflow run "${MODE}.yml" \ - --repo "${{ github.repository }}" \ - --ref "${{ github.ref_name }}" \ - "${args[@]}") - echo "Dispatched. Waiting for ${run_url} to complete." - run_id=${run_url##*/} + { + echo "inputs<> "$GITHUB_OUTPUT" - watch_exit_code=0 - gh run watch "$run_id" --repo "${{ github.repository }}" --exit-status --interval=10 \ - > /dev/null 2>&1 || watch_exit_code=$? - - if [[ $watch_exit_code -eq 0 ]]; then - echo "### ${PROJECT}: succeeded - ${run_url}" >> "$GITHUB_STEP_SUMMARY" - else - echo "### ${PROJECT}: FAILED - ${run_url}" >> "$GITHUB_STEP_SUMMARY" - echo "::error::${MODE} failed for ${PROJECT}: ${run_url}" - fi - exit $watch_exit_code + - name: Dispatch ${{ inputs.mode }} and wait + uses: ./.github/actions/dispatch-and-wait + with: + repo: ${{ github.repository }} + workflow: ${{ inputs.mode }}.yml + ref: ${{ github.ref_name }} + inputs: ${{ steps.inputs.outputs.inputs }} + summary-title: ${{ matrix.project }} + token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} diff --git a/README.md b/README.md index 5d391af..121e1aa 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Shared GitHub Actions workflows and composite actions for Spring Cloud projects. | [determine-matrix](.github/actions/determine-matrix/) | Reads [config/projects.json](config/projects.json) and produces a build matrix (branches × JDK versions) for the current repo and event. Supports OSS/commercial, scheduled vs single-branch, and comma-separated branch overrides. | [README](.github/actions/determine-matrix/README.md) | | [create-commercial-branch](.github/actions/create-commercial-branch/) | Copies the content of an OSS branch into a new orphan branch in a commercial repository, with no OSS git history. Optionally sets the new branch as the repo default. | [README](.github/actions/create-commercial-branch/README.md) | | [generate-workflows-for-branch](.github/actions/generate-workflows-for-branch/) | Copies release-train action files and runs the workflow generator for a single repository branch. Used by both the generator workflow and `create-hotfix-branch`. | [README](.github/actions/generate-workflows-for-branch/README.md) | +| [dispatch-and-wait](.github/actions/dispatch-and-wait/) | Dispatches a `workflow_dispatch` workflow, waits for the run to complete, and fails unless its conclusion is `success`. Used to trigger `release-train-join`, `release-train-ready`, and the per-project runs of `create-release-branches`. | [README](.github/actions/dispatch-and-wait/README.md) | | [create-milestone](.github/actions/create-milestone/) | Creates a milestone in a GitHub repository for a given version if one does not already exist. | [README](.github/actions/create-milestone/README.md) | | [close-milestone](.github/actions/close-milestone/) | Closes a milestone by title, optionally moving any issues still open in it to another milestone first. Missing or already-closed milestones are skipped, not failed. | [README](.github/actions/close-milestone/README.md) | | [copy-dependabot-config](.github/actions/copy-dependabot-config/) | Copies `dependabot.yml` / `dependabot.yaml` from one branch to another within the same repository as a separate commit. | [README](.github/actions/copy-dependabot-config/README.md) |