Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/actions/github/retry-transient-failures/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Retry Transient Workflow Failures
description: Inspect failed workflow jobs for transient GitHub-side failures and request a rerun when every failed job matches the configured signatures.

inputs:
run-id:
description: Workflow run identifier to inspect.
required: true
run-attempt:
description: Current workflow run attempt number.
required: true
workflow-name:
description: Human-readable workflow name for summaries.
required: true
max-run-attempts:
description: Maximum workflow run attempts before retry is skipped.
required: false
default: '2'

outputs:
status:
description: Retry decision status.
value: ${{ steps.retry.outputs.status }}
summary:
description: Markdown summary for the retry decision.
value: ${{ steps.retry.outputs.summary }}

runs:
using: composite
steps:
- id: retry
shell: bash
env:
INPUT_RUN_ID: ${{ inputs.run-id }}
INPUT_RUN_ATTEMPT: ${{ inputs.run-attempt }}
INPUT_WORKFLOW_NAME: ${{ inputs.workflow-name }}
INPUT_MAX_RUN_ATTEMPTS: ${{ inputs.max-run-attempts }}
run: ${{ github.action_path }}/run.sh
186 changes: 186 additions & 0 deletions .github/actions/github/retry-transient-failures/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
set -euo pipefail

run_id="${INPUT_RUN_ID}"
run_attempt="${INPUT_RUN_ATTEMPT}"
workflow_name="${INPUT_WORKFLOW_NAME}"
max_run_attempts="${INPUT_MAX_RUN_ATTEMPTS:-2}"

if [ -z "${GH_TOKEN:-}" ]; then
echo "GH_TOKEN is required." >&2

exit 1
fi

failed_jobs_csv=""
matched_jobs_csv=""
uninspectable_jobs_csv=""

csv_append() {
local current="$1"
local value="$2"

if [ -z "${current}" ]; then
printf '%s' "${value}"

return
fi

printf '%s,%s' "${current}" "${value}"
}

csv_to_summary_list() {
local csv="$1"
local rendered=()
local item=""

if [ -z "${csv}" ]; then
return
fi

IFS=',' read -r -a rendered <<< "${csv}"

for item in "${rendered[@]}"; do
printf '`%s`' "${item}"

if [ "${item}" != "${rendered[${#rendered[@]}-1]}" ]; then
printf ', '
fi
done
}

build_summary() {
local status="$1"
local lines=(
"## Transient Failure Retry Summary"
""
"- Workflow: \`${workflow_name}\`"
"- Run ID: \`${run_id}\`"
"- Run attempt: \`${run_attempt}\`"
"- Retry status: \`${status}\`"
)

if [ -n "${failed_jobs_csv}" ]; then
lines+=("- Failed jobs inspected: $(csv_to_summary_list "${failed_jobs_csv}")")
fi

if [ -n "${matched_jobs_csv}" ]; then
lines+=("- Jobs with transient GitHub failure signatures: $(csv_to_summary_list "${matched_jobs_csv}")")
fi

if [ -n "${uninspectable_jobs_csv}" ]; then
lines+=("- Failed jobs with unreadable logs: $(csv_to_summary_list "${uninspectable_jobs_csv}")")
fi

case "${status}" in
rerun-requested)
lines+=("- Action: Requested a rerun of failed jobs because every inspectable failed job matched transient GitHub-side error signatures.")
;;
skipped-run-attempt-limit)
lines+=("- Action: Skipped rerun because the workflow already reached the configured retry limit.")
;;
skipped-no-failed-jobs)
lines+=("- Action: Skipped rerun because the workflow reported failure without failed jobs to inspect.")
;;
skipped-no-transient-match)
lines+=("- Action: Skipped rerun because at least one failed job did not match the transient GitHub-side signatures.")
;;
skipped-uninspectable-logs)
lines+=("- Action: Skipped rerun because at least one failed job log could not be downloaded through the GitHub Actions API.")
;;
esac

printf '%s\n' "${lines[@]}"
}

write_summary_output() {
local summary="$1"
local delimiter="SUMMARY_$(date +%s%N)"

{
printf 'summary<<%s\n' "${delimiter}"
printf '%s\n' "${summary}"
printf '%s\n' "${delimiter}"
} >> "${GITHUB_OUTPUT}"
}

write_status_and_summary() {
local status="$1"
local summary

summary="$(build_summary "${status}")"

printf 'status=%s\n' "${status}" >> "${GITHUB_OUTPUT}"
write_summary_output "${summary}"
}

log_matches_transient_signature() {
local log_file="$1"

grep -Eiq \
"RPC failed; HTTP 5[0-9][0-9]|expected flush after ref listing|expected 'packfile'|remote:[[:space:]]+Internal Server Error|requested URL returned error:[[:space:]]*5[0-9][0-9]|fatal:[[:space:]]+unable to access 'https://github\\.com/.*': The requested URL returned error:[[:space:]]*5[0-9][0-9]" \
"${log_file}"
}

download_job_logs() {
local job_id="$1"
local output_file="$2"

curl \
-sS -L \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-o "${output_file}" \
-w '%{http_code}' \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/jobs/${job_id}/logs"
}

if [ "${run_attempt}" -ge "${max_run_attempts}" ]; then
write_status_and_summary "skipped-run-attempt-limit"

exit 0
fi

jobs_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100")"
failed_jobs_json="$(jq -c '.jobs[] | select(.conclusion == "failure")' <<< "${jobs_json}")"

if [ -z "${failed_jobs_json}" ]; then
write_status_and_summary "skipped-no-failed-jobs"

exit 0
fi

while IFS= read -r failed_job; do
[ -n "${failed_job}" ] || continue

job_id="$(jq -r '.id' <<< "${failed_job}")"
job_name="$(jq -r '.name' <<< "${failed_job}")"
failed_jobs_csv="$(csv_append "${failed_jobs_csv}" "${job_name}")"

temporary_log_file="$(mktemp)"

log_status_code="$(download_job_logs "${job_id}" "${temporary_log_file}")"

if [ "${log_status_code}" != "200" ]; then
uninspectable_jobs_csv="$(csv_append "${uninspectable_jobs_csv}" "${job_name} (${log_status_code})")"
rm -f "${temporary_log_file}"
write_status_and_summary "skipped-uninspectable-logs"

exit 0
fi

if ! log_matches_transient_signature "${temporary_log_file}"; then
rm -f "${temporary_log_file}"
write_status_and_summary "skipped-no-transient-match"

exit 0
fi

matched_jobs_csv="$(csv_append "${matched_jobs_csv}" "${job_name}")"
rm -f "${temporary_log_file}"
done <<< "${failed_jobs_json}"

gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/rerun-failed-jobs" >/dev/null

write_status_and_summary "rerun-requested"
38 changes: 38 additions & 0 deletions .github/actions/wiki/refresh-release-pointer/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Refresh Release Wiki Pointer
description: Rebuild the authoritative wiki branch from the merged release state and expose whether the parent repository submodule pointer changed.

inputs:
target:
description: Wiki target directory or submodule path.
required: false
default: .github/wiki
publish-branch:
description: Wiki branch to refresh from the merged release state.
required: false
default: master
commit-message:
description: Commit message used for the wiki branch refresh.
required: false
default: Refresh wiki docs after merged release

outputs:
published:
description: Whether the wiki publish branch received a new commit.
value: ${{ steps.refresh.outputs.published }}
pointer-changed:
description: Whether the parent repository submodule pointer changed after the wiki publish branch refresh.
value: ${{ steps.refresh.outputs.pointer-changed }}
publish-sha:
description: Final commit SHA at the refreshed wiki publish branch head.
value: ${{ steps.refresh.outputs.publish-sha }}

runs:
using: composite
steps:
- id: refresh
shell: bash
env:
INPUT_TARGET: ${{ inputs.target }}
INPUT_PUBLISH_BRANCH: ${{ inputs.publish-branch }}
INPUT_COMMIT_MESSAGE: ${{ inputs.commit-message }}
run: ${{ github.action_path }}/run.sh
54 changes: 54 additions & 0 deletions .github/actions/wiki/refresh-release-pointer/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail

# Isolate nested Git commands from caller-specific repository environment such as hooks.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_INTERNAL_SUPER_PREFIX GIT_COMMON_DIR

target="${INPUT_TARGET:-.github/wiki}"
publish_branch="${INPUT_PUBLISH_BRANCH:-master}"
commit_message="${INPUT_COMMIT_MESSAGE:-Refresh wiki docs after merged release}"

git -C "${target}" fetch origin "${publish_branch}"

if ! git -C "${target}" switch -C "${publish_branch}" --track "origin/${publish_branch}" >/dev/null 2>&1; then
git -C "${target}" switch "${publish_branch}" >/dev/null 2>&1
fi

git -C "${target}" reset --hard "origin/${publish_branch}"
git -C "${target}" clean -fd

dev-tools wiki --target="${target}"

if [ -z "$(git -C "${target}" status --porcelain)" ]; then
pointer_changed="false"

if ! git diff --quiet -- "${target}"; then
pointer_changed="true"
fi

{
echo "published=false"
echo "pointer-changed=${pointer_changed}"
echo "publish-sha=$(git -C "${target}" rev-parse HEAD)"
Comment thread
coisa marked this conversation as resolved.
} >> "${GITHUB_OUTPUT}"

exit 0
fi

git -C "${target}" config user.name "${GIT_AUTHOR_NAME:-github-actions[bot]}"
git -C "${target}" config user.email "${GIT_AUTHOR_EMAIL:-41898282+github-actions[bot]@users.noreply.github.com}"
git -C "${target}" add -A
git -C "${target}" commit -m "${commit_message}"
git -C "${target}" push origin "HEAD:${publish_branch}"

pointer_changed="false"

if ! git diff --quiet -- "${target}"; then
pointer_changed="true"
fi

{
echo "published=true"
echo "pointer-changed=${pointer_changed}"
echo "publish-sha=$(git -C "${target}" rev-parse HEAD)"
} >> "${GITHUB_OUTPUT}"
2 changes: 1 addition & 1 deletion .github/wiki
Submodule wiki updated from 9cb08e to c30fa7
30 changes: 30 additions & 0 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ jobs:
token: ${{ github.token }}
ref: ${{ github.event.pull_request.base.ref }}
fetch-depth: 0
submodules: recursive
- name: Checkout dev-tools workflow action source
uses: actions/checkout@v6
with:
Expand All @@ -311,6 +312,8 @@ jobs:
php-version: ${{ needs.resolve_php.outputs.php-version }}
root-version: ${{ env.CHANGELOG_ROOT_VERSION }}
install-options: --prefer-dist --no-progress --no-interaction --no-plugins --no-scripts
safe-directories: |
${{ github.workspace }}/.github/wiki

- name: Resolve merged release version
id: version
Expand All @@ -332,6 +335,30 @@ jobs:
version: ${{ steps.version.outputs.value }}
target: ${{ github.event.pull_request.merge_commit_sha || github.sha }}

- name: Refresh release wiki branch from merged main
id: refresh_release_wiki
uses: ./.dev-tools-actions/.github/actions/wiki/refresh-release-pointer
with:
commit-message: Refresh wiki docs after release v${{ steps.version.outputs.value }}

- name: Commit release wiki submodule pointer
if: ${{ steps.refresh_release_wiki.outputs.pointer-changed == 'true' }}
id: release_wiki_pointer_commit
uses: EndBug/add-and-commit@v10
with:
add: .github/wiki
message: Refresh wiki submodule pointer after release v${{ steps.version.outputs.value }}
default_author: github_actions
pull: "--rebase --autostash"
push: true
Comment thread
coisa marked this conversation as resolved.

- name: Dispatch tests for release wiki pointer commit
if: ${{ steps.refresh_release_wiki.outputs.pointer-changed == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: gh workflow run tests.yml --ref "${BASE_REF}" -f publish-required-statuses=true

- uses: actions/checkout@v6
- name: Checkout dev-tools workflow action source
uses: actions/checkout@v6
Expand Down Expand Up @@ -359,6 +386,9 @@ jobs:
- Published tag: `v${{ steps.version.outputs.value }}`
- Release operation: `${{ steps.publish_release.outputs.operation }}`
- Release URL: ${{ steps.publish_release.outputs.url }}
- Wiki publish refresh: `${{ steps.refresh_release_wiki.outputs.published == 'true' && 'published' || 'unchanged' }}`
- Wiki pointer reconciliation: `${{ steps.refresh_release_wiki.outputs.pointer-changed == 'true' && 'updated' || 'unchanged' }}`
- Required test dispatch: `${{ steps.refresh_release_wiki.outputs.pointer-changed == 'true' && 'requested' || 'not needed' }}`
- Project items released: `${{ steps.release_project_status.outputs.moved-count }}`
- Project items skipped: `${{ steps.release_project_status.outputs.skipped-count }}`
- Project source statuses: `${{ steps.release_project_status.outputs.source-statuses }}`
Loading
Loading