diff --git a/.github/workflows/chart-pr.yml b/.github/workflows/chart-pr.yml index fb979cf..be751b5 100644 --- a/.github/workflows/chart-pr.yml +++ b/.github/workflows/chart-pr.yml @@ -24,10 +24,30 @@ jobs: name: Semver label runs-on: ubuntu-latest permissions: + contents: read # check out the label script pull-requests: write outputs: bump: ${{ steps.label.outputs.bump }} steps: + - name: Locate this workflow + env: + WORKFLOW_REPOSITORY: ${{ job.workflow_repository }} + WORKFLOW_SHA: ${{ job.workflow_sha }} + run: | + if [ -z "$WORKFLOW_REPOSITORY" ] || [ -z "$WORKFLOW_SHA" ]; then + echo "::error::job.workflow_repository and job.workflow_sha are empty: this workflow has to run as a reusable workflow on github.com" + exit 1 + fi + echo "Using $WORKFLOW_REPOSITORY@$WORKFLOW_SHA" + + - name: Check out the label script + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: chart-workflows + persist-credentials: false + # The labels the pull request has now, not the ones of the event that started the run: a pull # request opened with a label (gh pr create --label) gets it right after the `opened` event, and # the `labeled` event of the workflow token starts no run. Reading them now also lets a re-run @@ -36,18 +56,13 @@ jobs: id: label env: GH_TOKEN: ${{ github.token }} + EVENT: pull_request PR: ${{ github.event.pull_request.number }} - run: | - LABELS=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq '[.labels[].name]') - bumps=$(jq -r '[.[] | select(. == "major" or . == "minor" or . == "patch")] | join(" ")' <<< "$LABELS") - if [ "$(wc -w <<< "$bumps")" -ne 1 ]; then - echo "::error::The pull request needs exactly one of the labels major, minor or patch (found: ${bumps:-none})" - exit 1 - fi - echo "bump=$bumps" >> "$GITHUB_OUTPUT" + run: chart-workflows/scripts/semver-label.sh + # only when the label is the problem: a failure of the GitHub API is not the author's to fix - name: Ask for the label - if: failure() && steps.label.outcome == 'failure' + if: failure() && steps.label.outputs.label-missing == 'true' env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/semver-version.yml b/.github/workflows/semver-version.yml index 522e6af..e29db07 100644 --- a/.github/workflows/semver-version.yml +++ b/.github/workflows/semver-version.yml @@ -77,8 +77,9 @@ jobs: DEFAULT_BUMP: ${{ inputs.default-bump }} run: ../semver-workflows/scripts/semver-label.sh + # only when the label is the problem: a failure of the GitHub API is not the author's to fix - name: Ask for the label - if: failure() && steps.label.outcome == 'failure' && github.event_name == 'pull_request' + if: failure() && steps.label.outputs.label-missing == 'true' && github.event_name == 'pull_request' env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.pull_request.number }} diff --git a/README.md b/README.md index f83c934..81e5631 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ jobs: For repositories that release Helm charts from a `charts/` directory, with the version of each chart driven by the semver label of the pull request that changes it. On every pull request it: -1. requires exactly one of the labels `major`, `minor` or `patch`, and comments on the pull request when it is missing. The labels are read when the job runs, not from the event that started it, so a label added right after the pull request was opened counts, and re-running the job picks up a label added later; +1. requires exactly one of the labels `major`, `minor` or `patch`, and comments on the pull request when it is missing. The labels are read when the job runs, not from the event that started it, so a label added right after the pull request was opened counts, and re-running the job picks up a label added later. The call to the GitHub API is retried, and when it keeps failing the job fails without asking for the label; 2. bumps the `version` of every chart the pull request changes: its version on the base branch, bumped by the label. A chart that is not on the base branch yet keeps its version; 3. commits the new versions ("Update helm chart versions") to the pull request branch. A pull request from a fork, which the workflow token cannot push to, fails instead, listing the versions its author has to set. diff --git a/scripts/semver-label.sh b/scripts/semver-label.sh index fda6a6f..785b194 100755 --- a/scripts/semver-label.sh +++ b/scripts/semver-label.sh @@ -7,6 +7,10 @@ # major, minor and patch. On a push, DEFAULT_BUMP, when set, is used instead of failing when no # merged pull request contains the commit or it has no such label. # +# The GitHub API calls are retried: a transient error answers with an HTML page or a 5xx. When they +# keep failing, the script exits with 2, and with 1 when the label is the problem; only then it also +# writes label-missing=true, so that the caller asks for the label only when it is missing. +# # Environment: # EVENT pull_request or push (required) # PR pull request number, on a pull request @@ -14,7 +18,9 @@ # DEFAULT_BUMP major, minor or patch for a push without a labelled pull request (default: none) # REPOSITORY owner/repo (default: $GITHUB_REPOSITORY) # GH gh binary (default: gh) -# GITHUB_OUTPUT when set, receives bump= +# RETRIES attempts of each GitHub API call (default: 3) +# RETRY_DELAY seconds between them (default: 5) +# GITHUB_OUTPUT when set, receives bump=, or label-missing=true set -euo pipefail @@ -22,20 +28,46 @@ set -euo pipefail DEFAULT_BUMP="${DEFAULT_BUMP:-}" REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY must name the repository}}" GH="${GH:-gh}" +RETRIES="${RETRIES:-3}" +RETRY_DELAY="${RETRY_DELAY:-5}" case "$DEFAULT_BUMP" in ""|major|minor|patch) ;; *) echo "::error::DEFAULT_BUMP must be major, minor or patch, got '$DEFAULT_BUMP'" >&2; exit 1 ;; esac +# api : prints the response, retrying the call +api() { + local attempt response + for ((attempt = 1; attempt <= RETRIES; attempt++)); do + if response=$("$GH" api "$@"); then + printf '%s\n' "$response" + return 0 + fi + if [ "$attempt" -lt "$RETRIES" ]; then + echo "The GitHub API call failed (attempt $attempt of $RETRIES), retrying in ${RETRY_DELAY}s" >&2 + sleep "$RETRY_DELAY" + fi + done + echo "::error::The GitHub API call failed $RETRIES times, so the semver label could not be read" >&2 + return 1 +} + +# label_missing : fails because of the label itself +label_missing() { + echo "::error::$1" >&2 + echo "label-missing=true" >> "${GITHUB_OUTPUT:-/dev/null}" + exit 1 +} + labels="[]" if [ "$EVENT" = pull_request ] || [ "$EVENT" = pull_request_target ]; then : "${PR:?PR must be the pull request number}" - labels=$("$GH" api "repos/$REPOSITORY/pulls/$PR" --jq '[.labels[].name]') + labels=$(api "repos/$REPOSITORY/pulls/$PR" --jq '[.labels[].name]') || exit 2 else : "${SHA:?SHA must be the pushed commit}" - pr=$("$GH" api "repos/$REPOSITORY/commits/$SHA/pulls" \ - --jq '[.[] | select(.merged_at != null)][0] // empty | {number, labels: [.labels[].name]}') + pr=$(api "repos/$REPOSITORY/commits/$SHA/pulls" \ + --jq '[.[] | select(.merged_at != null)][0] // empty | {number, labels: [.labels[].name]}') || exit 2 if [ -z "$pr" ]; then if [ -n "$DEFAULT_BUMP" ]; then echo "No merged pull request contains $SHA, releasing it as $DEFAULT_BUMP" >&2 @@ -43,8 +75,7 @@ else echo "$DEFAULT_BUMP" exit 0 fi - echo "::error::No merged pull request contains $SHA, so there is no semver label to release it with" >&2 - exit 1 + label_missing "No merged pull request contains $SHA, so there is no semver label to release it with" fi echo "Releasing pull request #$(jq -r .number <<< "$pr")" >&2 labels=$(jq -c .labels <<< "$pr") @@ -56,8 +87,7 @@ if [ "$(wc -w <<< "$bumps")" -ne 1 ]; then echo "The pull request has no semver label, releasing it as $DEFAULT_BUMP" >&2 bumps="$DEFAULT_BUMP" else - echo "::error::The pull request needs exactly one of the labels major, minor or patch (found: ${bumps:-none})" >&2 - exit 1 + label_missing "The pull request needs exactly one of the labels major, minor or patch (found: ${bumps:-none})" fi fi diff --git a/tests/semver-label.bats b/tests/semver-label.bats index 9fb8072..730b6a8 100644 --- a/tests/semver-label.bats +++ b/tests/semver-label.bats @@ -1,6 +1,7 @@ #!/usr/bin/env bats # Tests for scripts/semver-label.sh, with a fake gh. $PR_LABELS are the labels of the pull request, -# and $MERGED_PR the merged pull request a pushed commit comes from (empty for none). +# and $MERGED_PR the merged pull request a pushed commit comes from (empty for none). The first +# $API_FAILURES calls fail the way gh does when GitHub answers with an error page. load helpers @@ -10,12 +11,18 @@ setup() { mkdir -p "$BATS_TEST_TMPDIR/repo" && cd "$BATS_TEST_TMPDIR/repo" fake_command gh cat > "$BATS_TEST_TMPDIR/gh.sh" <<'EOF' +calls=$(($(cat "$BATS_TEST_TMPDIR/gh.calls" 2>/dev/null || echo 0) + 1)) +echo "$calls" > "$BATS_TEST_TMPDIR/gh.calls" +if [ "$calls" -le "${API_FAILURES:-0}" ]; then + echo "invalid character '<' looking for beginning of value" >&2 + exit 1 +fi case "$2" in repos/owner/repo/pulls/*) echo "${PR_LABELS:-[]}" ;; repos/owner/repo/commits/*/pulls) echo "${MERGED_PR:-}" ;; esac EOF - export REPOSITORY=owner/repo GITHUB_OUTPUT="$BATS_TEST_TMPDIR/output" + export REPOSITORY=owner/repo GITHUB_OUTPUT="$BATS_TEST_TMPDIR/output" RETRY_DELAY=0 : > "$GITHUB_OUTPUT" } @@ -27,10 +34,11 @@ EOF grep -q "api repos/owner/repo/pulls/7" "$BATS_TEST_TMPDIR/gh.log" } -@test "a pull request without a semver label fails" { +@test "a pull request without a semver label fails, saying the label is missing" { EVENT=pull_request PR=7 PR_LABELS='["documentation"]' run "$SCRIPT" [ "$status" -eq 1 ] [[ "$output" == *"needs exactly one of the labels major, minor or patch (found: none)"* ]] + grep -qx "label-missing=true" "$GITHUB_OUTPUT" } @test "a pull request with two semver labels fails" { @@ -80,3 +88,30 @@ EOF EVENT=push SHA=abc DEFAULT_BUMP=huge run "$SCRIPT" [ "$status" -eq 1 ] } + +@test "a failed API call is retried" { + EVENT=pull_request PR=7 PR_LABELS='["patch"]' API_FAILURES=2 run "$SCRIPT" + [ "$status" -eq 0 ] + [ "${lines[-1]}" = patch ] + [ "$(grep -c "api repos/owner/repo/pulls/7" "$BATS_TEST_TMPDIR/gh.log")" -eq 3 ] + [[ "$output" == *"retrying"* ]] +} + +@test "an API that keeps failing fails without blaming the label" { + EVENT=pull_request PR=7 PR_LABELS='["patch"]' API_FAILURES=3 run "$SCRIPT" + [ "$status" -eq 2 ] + [[ "$output" == *"The GitHub API call failed 3 times"* ]] + ! grep -q "label-missing" "$GITHUB_OUTPUT" +} + +@test "the merged pull request of a push is read with retries too" { + EVENT=push SHA=abc MERGED_PR='{"number":9,"labels":["minor"]}' API_FAILURES=1 run "$SCRIPT" + [ "$status" -eq 0 ] + [ "${lines[-1]}" = minor ] +} + +@test "a push that no merged pull request carries says the label is missing" { + EVENT=push SHA=abc run "$SCRIPT" + [ "$status" -eq 1 ] + grep -qx "label-missing=true" "$GITHUB_OUTPUT" +}