diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f78b8d..004290c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,14 @@ on: workflow_dispatch: jobs: + downstream-wait: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Test downstream wait + run: bash downstream/wait.test.bash + shopware-version-fallback: runs-on: ubuntu-latest steps: @@ -113,4 +121,4 @@ jobs: if [[ ! "$NEXT_MINOR" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "next-minor looks wrong: '${NEXT_MINOR}'" exit 1 - fi \ No newline at end of file + fi diff --git a/downstream/README.md b/downstream/README.md index 9366d2d..989f3f1 100644 --- a/downstream/README.md +++ b/downstream/README.md @@ -22,6 +22,7 @@ Trigger a downstream workflow in another repository and wait for it to finish. | `env` | Environment variables to pass to downstream workflow | No | - | | `identity` | Identity for octo-sts | No | `upstream` | | `token` | Token to authenticate (if not provided, octo-sts is used) | No | - | +| `upstream-token` | Token used to cancel the upstream workflow run when the downstream run was cancelled | No | - | | `timeout` | Timeout for the downstream workflow | No | `30m` | | `poll_interval` | Poll interval for checking status | No | `2m` | diff --git a/downstream/action.yml b/downstream/action.yml index 6a9fb77..0a8a584 100644 --- a/downstream/action.yml +++ b/downstream/action.yml @@ -42,6 +42,10 @@ inputs: description: Token used to authenticate with the downstream repo. If not provided octo-sts is used required: false default: "" + upstream-token: + description: Token used to cancel the upstream workflow run when the downstream run was cancelled + required: false + default: "" timeout: description: Timeout for the downstream required: false @@ -116,4 +120,7 @@ runs: env: GH_TOKEN: ${{ inputs.token || steps.sts.outputs.token }} REPO: ${{ inputs.repo }} + UPSTREAM_TOKEN: ${{ inputs.upstream-token }} + UPSTREAM_REPOSITORY: ${{ github.repository }} + UPSTREAM_RUN_ID: ${{ github.run_id }} run: timeout "${{ inputs.timeout }}" ${GITHUB_ACTION_PATH}/wait.bash "$REPO" "${{ inputs.poll_interval }}" "${{ steps.trigger.outputs.run_url }}" diff --git a/downstream/wait.bash b/downstream/wait.bash index 5573921..6bafcd3 100755 --- a/downstream/wait.bash +++ b/downstream/wait.bash @@ -2,12 +2,19 @@ REPO=${1} POLL_INTERVAL=${2} DOWNSTREAM_RUN_URL=${3} +CANCEL_REQUESTED=0 DOWNSTREAM_RUN_ID=$(basename "$DOWNSTREAM_RUN_URL") trap on_sigterm SIGTERM on_sigterm() { + if [[ "${CANCEL_REQUESTED}" == "1" ]]; then + # GitHub sent SIGTERM after accepting our cancellation request. + echo "Upstream workflow cancellation is taking over." + exit 130 + fi + echo "Timeout reached" fail } @@ -17,6 +24,27 @@ fail() { exit 1 } +cancel_upstream() { + if [[ -z "${UPSTREAM_TOKEN:-}" || -z "${UPSTREAM_REPOSITORY:-}" || -z "${UPSTREAM_RUN_ID:-}" ]]; then + return 1 + fi + + echo "Cancelling upstream workflow run ${UPSTREAM_RUN_ID} in ${UPSTREAM_REPOSITORY}." + + if ! GH_TOKEN="${UPSTREAM_TOKEN}" gh run cancel "${UPSTREAM_RUN_ID}" --repo "${UPSTREAM_REPOSITORY}"; then + echo "Could not cancel upstream workflow run." + return 1 + fi + + CANCEL_REQUESTED=1 + + # gh run cancel only requests cancellation. Keep this step alive so GitHub + # can mark the upstream run as cancelled instead of racing our own failure. + while true; do + sleep 60 + done +} + echo "Downstream workflow: ${DOWNSTREAM_RUN_URL}" ATTEMPT=1 @@ -36,9 +64,15 @@ while true; do sleep 60 done +if [[ "${STATUS}" == "cancelled" ]]; then + echo "Downstream workflow was cancelled." + cancel_upstream + fail +fi + if [[ "${STATUS}" != "success" ]]; then - echo "Downstream workflow failed." + echo "Downstream workflow concluded with '${STATUS}'." fail fi -echo "Downstream workflow succeeded!" \ No newline at end of file +echo "Downstream workflow succeeded!" diff --git a/downstream/wait.test.bash b/downstream/wait.test.bash new file mode 100644 index 0000000..834d51a --- /dev/null +++ b/downstream/wait.test.bash @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +TMP_DIR=$(mktemp -d) + +trap 'rm -rf "${TMP_DIR}"' EXIT + +cat > "${TMP_DIR}/gh" <<'EOF' +#!/usr/bin/env bash +if [[ "${1} ${2}" == "run view" ]]; then + printf '{"status":"completed","conclusion":"%s"}\n' "${GH_CONCLUSION}" + exit 0 +fi + +if [[ "${1} ${2}" == "run cancel" ]]; then + echo "cancel called" + exit "${GH_CANCEL_EXIT:-0}" +fi + +exit 1 +EOF +chmod +x "${TMP_DIR}/gh" + +run_case() { + local conclusion=${1} + local expected_code=${2} + local expected_message=${3} + local output + local code + + set +e + output=$(PATH="${TMP_DIR}:${PATH}" GH_CONCLUSION="${conclusion}" bash "${SCRIPT_DIR}/wait.bash" shopware/example 1 https://github.com/shopware/example/actions/runs/1 2>&1) + code=$? + set -e + + if [[ "${code}" -ne "${expected_code}" ]]; then + echo "Expected exit code ${expected_code} for ${conclusion}, got ${code}" + echo "${output}" + exit 1 + fi + + if [[ "${output}" != *"${expected_message}"* ]]; then + echo "Expected output for ${conclusion} to contain: ${expected_message}" + echo "${output}" + exit 1 + fi +} + +run_case success 0 "Downstream workflow succeeded!" +run_case failure 1 "Downstream workflow concluded with 'failure'." +run_case cancelled 1 "Downstream workflow was cancelled." + +set +e +output=$(PATH="${TMP_DIR}:${PATH}" GH_CONCLUSION="cancelled" GH_CANCEL_EXIT=1 UPSTREAM_TOKEN=token UPSTREAM_REPOSITORY=shopware/shopware UPSTREAM_RUN_ID=1 bash "${SCRIPT_DIR}/wait.bash" shopware/example 1 https://github.com/shopware/example/actions/runs/1 2>&1) +code=$? +set -e + +if [[ "${code}" -ne 1 || "${output}" != *"Could not cancel upstream workflow run."* ]]; then + echo "Expected failed upstream cancellation to fall back to a failed downstream wait" + echo "${output}" + exit 1 +fi