From 221fb993be40d9922739c7b6aa7b35ab6cec0568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 30 Apr 2026 03:44:52 -0300 Subject: [PATCH 1/6] Stabilize wiki retry handling and release refreshes (#309) --- .../retry-transient-failures/action.yml | 37 ++ .../github/retry-transient-failures/run.sh | 186 ++++++++++ .../wiki/refresh-release-pointer/action.yml | 38 +++ .../wiki/refresh-release-pointer/run.sh | 48 +++ .github/workflows/changelog.yml | 22 ++ .../workflows/retry-transient-failures.yml | 137 +------- .github/workflows/wiki-maintenance.yml | 24 +- CHANGELOG.md | 4 + .../RefreshReleaseWikiPointerActionTest.php | 271 +++++++++++++++ .../RetryTransientFailuresActionTest.php | 322 ++++++++++++++++++ 10 files changed, 959 insertions(+), 130 deletions(-) create mode 100644 .github/actions/github/retry-transient-failures/action.yml create mode 100755 .github/actions/github/retry-transient-failures/run.sh create mode 100644 .github/actions/wiki/refresh-release-pointer/action.yml create mode 100755 .github/actions/wiki/refresh-release-pointer/run.sh create mode 100644 tests/GitHubActions/RefreshReleaseWikiPointerActionTest.php create mode 100644 tests/GitHubActions/RetryTransientFailuresActionTest.php diff --git a/.github/actions/github/retry-transient-failures/action.yml b/.github/actions/github/retry-transient-failures/action.yml new file mode 100644 index 0000000000..4877481424 --- /dev/null +++ b/.github/actions/github/retry-transient-failures/action.yml @@ -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 diff --git a/.github/actions/github/retry-transient-failures/run.sh b/.github/actions/github/retry-transient-failures/run.sh new file mode 100755 index 0000000000..1f5c10691f --- /dev/null +++ b/.github/actions/github/retry-transient-failures/run.sh @@ -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" diff --git a/.github/actions/wiki/refresh-release-pointer/action.yml b/.github/actions/wiki/refresh-release-pointer/action.yml new file mode 100644 index 0000000000..8f02e057f7 --- /dev/null +++ b/.github/actions/wiki/refresh-release-pointer/action.yml @@ -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 diff --git a/.github/actions/wiki/refresh-release-pointer/run.sh b/.github/actions/wiki/refresh-release-pointer/run.sh new file mode 100755 index 0000000000..9d8d6e0a87 --- /dev/null +++ b/.github/actions/wiki/refresh-release-pointer/run.sh @@ -0,0 +1,48 @@ +#!/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 + { + echo "published=false" + echo "pointer-changed=false" + echo "publish-sha=$(git -C "${target}" rev-parse HEAD)" + } >> "${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}" diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 7507af68ad..4e0fd7a05d 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -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: @@ -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 @@ -332,6 +335,23 @@ 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 + - uses: actions/checkout@v6 - name: Checkout dev-tools workflow action source uses: actions/checkout@v6 @@ -359,6 +379,8 @@ 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' }}` - 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 }}` diff --git a/.github/workflows/retry-transient-failures.yml b/.github/workflows/retry-transient-failures.yml index d443338cfc..94663901ce 100644 --- a/.github/workflows/retry-transient-failures.yml +++ b/.github/workflows/retry-transient-failures.yml @@ -31,137 +31,16 @@ jobs: runs-on: ubuntu-latest steps: + - uses: actions/checkout@v6 + - id: retry - uses: actions/github-script@v9 + uses: ./.github/actions/github/retry-transient-failures + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const transientPatterns = [ - /RPC failed; HTTP 5\d\d/i, - /expected flush after ref listing/i, - /expected 'packfile'/i, - /remote:\s+Internal Server Error/i, - /requested URL returned error:\s*5\d\d/i, - /fatal:\s+unable to access 'https:\/\/github\.com\/.*': The requested URL returned error:\s*5\d\d/i, - ]; - - const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); - const runId = Number.parseInt(`${{ github.event.workflow_run.id }}`, 10); - const runAttempt = Number.parseInt(`${{ github.event.workflow_run.run_attempt }}`, 10); - const workflowName = `${{ github.event.workflow_run.name }}`; - const maxRunAttempts = 2; - - const buildSummary = ({ status, failedJobs = [], matchedJobs = [] }) => { - const lines = [ - '## Transient Failure Retry Summary', - '', - `- Workflow: \`${workflowName}\``, - `- Run ID: \`${runId}\``, - `- Run attempt: \`${runAttempt}\``, - `- Retry status: \`${status}\``, - ]; - - if (failedJobs.length > 0) { - lines.push(`- Failed jobs inspected: ${failedJobs.map((job) => `\`${job}\``).join(', ')}`); - } - - if (matchedJobs.length > 0) { - lines.push(`- Jobs with transient GitHub failure signatures: ${matchedJobs.map((job) => `\`${job}\``).join(', ')}`); - } - - if (status === 'rerun-requested') { - lines.push('- Action: Requested a rerun of failed jobs because every failed job matched transient GitHub-side error signatures.'); - } - - if (status === 'skipped-run-attempt-limit') { - lines.push('- Action: Skipped rerun because the run already reached the configured retry limit.'); - } - - if (status === 'skipped-no-failed-jobs') { - lines.push('- Action: Skipped rerun because the workflow reported failure without failed jobs to inspect.'); - } - - if (status === 'skipped-no-transient-match') { - lines.push('- Action: Skipped rerun because at least one failed job did not match the transient GitHub-side signatures.'); - } - - return lines.join('\n'); - }; - - if (runAttempt >= maxRunAttempts) { - const summary = buildSummary({ status: 'skipped-run-attempt-limit' }); - core.setOutput('status', 'skipped-run-attempt-limit'); - core.setOutput('summary', summary); - - return; - } - - const jobsResponse = await github.rest.actions.listJobsForWorkflowRun({ - owner, - repo, - run_id: runId, - per_page: 100, - }); - - const failedJobs = jobsResponse.data.jobs.filter((job) => job.conclusion === 'failure'); - - if (failedJobs.length === 0) { - const summary = buildSummary({ status: 'skipped-no-failed-jobs' }); - core.setOutput('status', 'skipped-no-failed-jobs'); - core.setOutput('summary', summary); - - return; - } - - const matchedJobs = []; - - for (const job of failedJobs) { - const logsResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/actions/jobs/${job.id}/logs`, { - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, - 'X-GitHub-Api-Version': '2022-11-28', - }, - redirect: 'follow', - }); - - if (!logsResponse.ok) { - throw new Error(`Failed to download logs for job ${job.name}: ${logsResponse.status} ${logsResponse.statusText}`); - } - - const logText = await logsResponse.text(); - const hasTransientMatch = transientPatterns.some((pattern) => pattern.test(logText)); - - if (!hasTransientMatch) { - const summary = buildSummary({ - status: 'skipped-no-transient-match', - failedJobs: failedJobs.map((failedJob) => failedJob.name), - matchedJobs, - }); - - core.setOutput('status', 'skipped-no-transient-match'); - core.setOutput('summary', summary); - - return; - } - - matchedJobs.push(job.name); - } - - await github.request('POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs', { - owner, - repo, - run_id: runId, - }); - - const summary = buildSummary({ - status: 'rerun-requested', - failedJobs: failedJobs.map((job) => job.name), - matchedJobs, - }); - - core.setOutput('status', 'rerun-requested'); - core.setOutput('summary', summary); + run-id: ${{ github.event.workflow_run.id }} + run-attempt: ${{ github.event.workflow_run.run_attempt }} + workflow-name: ${{ github.event.workflow_run.name }} - name: Write step summary env: diff --git a/.github/workflows/wiki-maintenance.yml b/.github/workflows/wiki-maintenance.yml index 519780d1e9..d11d61f935 100644 --- a/.github/workflows/wiki-maintenance.yml +++ b/.github/workflows/wiki-maintenance.yml @@ -2,6 +2,12 @@ name: Maintain Wiki Publication on: workflow_call: + inputs: + release-branch-prefix: + description: Prefix used for release-preparation branches handled by changelog publication. + required: false + type: string + default: release/v permissions: contents: read @@ -12,7 +18,7 @@ env: jobs: publish: name: Publish Wiki Master - if: github.event_name == 'pull_request_target' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' + if: github.event_name == 'pull_request_target' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && !startsWith(github.event.pull_request.head.ref, inputs.release-branch-prefix) runs-on: ubuntu-latest permissions: contents: write @@ -78,6 +84,22 @@ jobs: - Publish validation: completed - Preview cleanup: `${{ env.WIKI_PREVIEW_BRANCH }}` deleted + skip_release_publish: + name: Skip Release Branch Wiki Publish + if: github.event_name == 'pull_request_target' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && startsWith(github.event.pull_request.head.ref, inputs.release-branch-prefix) + runs-on: ubuntu-latest + + steps: + - name: Explain release publish handling + run: | + { + echo "## Wiki Publish Summary" + echo + echo "- Publish branch: \`master\`" + echo "- Release branch: \`${{ github.event.pull_request.head.ref }}\`" + echo "- Action: skipped preview-branch publication because merged release branches are refreshed from the authoritative released state by \`changelog.yml\`." + } >> "$GITHUB_STEP_SUMMARY" + cleanup_closed_preview: name: Delete Closed PR Wiki Preview if: github.event_name == 'pull_request_target' && github.event.pull_request.merged == false diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b8e10514b..9725cbd649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Keep reusable-workflow retry automation from failing on unreadable child-job logs, while moving release-branch wiki publication to changelog-driven release refreshes that republish wiki content and reconcile the parent `.github/wiki` pointer from the authoritative released state (#309) + ## [1.24.5] - 2026-04-30 ### Fixed diff --git a/tests/GitHubActions/RefreshReleaseWikiPointerActionTest.php b/tests/GitHubActions/RefreshReleaseWikiPointerActionTest.php new file mode 100644 index 0000000000..fbcab13056 --- /dev/null +++ b/tests/GitHubActions/RefreshReleaseWikiPointerActionTest.php @@ -0,0 +1,271 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\GitHubActions; + +use FilesystemIterator; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; +use Symfony\Component\Process\Process; + +use function Safe\chmod; +use function Safe\file_get_contents; +use function Safe\file_put_contents; +use function Safe\mkdir; +use function Safe\rmdir; +use function Safe\unlink; + +#[CoversNothing] +final class RefreshReleaseWikiPointerActionTest extends TestCase +{ + private const string ACTION_PATH = __DIR__ . '/../../.github/actions/wiki/refresh-release-pointer'; + + private string $workspace; + + /** + * @return void + */ + protected function setUp(): void + { + $this->workspace = sys_get_temp_dir() . '/refresh-release-wiki-pointer-action-test-' . bin2hex(random_bytes(4)); + mkdir($this->workspace, 0o777, true); + } + + /** + * @return void + */ + protected function tearDown(): void + { + if (! is_dir($this->workspace)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->workspace, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + /** @var SplFileInfo $item */ + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + + continue; + } + + unlink($item->getPathname()); + } + + rmdir($this->workspace); + } + + /** + * @return void + */ + #[Test] + public function refreshWillPublishTheWikiBranchAndExposeTheParentPointerChange(): void + { + $workspace = $this->createWorkspaceWithWikiSubmodule(); + $this->createMockDevToolsBinary(true); + $outputFile = $this->workspace . '/github-output'; + + $this->runAction($workspace, $outputFile); + + $outputs = $this->parseKeyValueFile($outputFile); + $status = $this->runProcess(['git', 'status', '--short', '.github/wiki'], $workspace); + + self::assertSame('true', $outputs['published']); + self::assertSame('true', $outputs['pointer-changed']); + self::assertStringContainsString('.github/wiki', $status->getOutput()); + } + + /** + * @return void + */ + #[Test] + public function refreshWillSkipPublicationWhenTheRenderedWikiDoesNotChange(): void + { + $workspace = $this->createWorkspaceWithWikiSubmodule(); + $this->createMockDevToolsBinary(false); + $outputFile = $this->workspace . '/github-output'; + + $this->runAction($workspace, $outputFile); + + $outputs = $this->parseKeyValueFile($outputFile); + $status = $this->runProcess(['git', 'status', '--short', '.github/wiki'], $workspace); + + self::assertSame('false', $outputs['published']); + self::assertSame('false', $outputs['pointer-changed']); + self::assertSame('', trim($status->getOutput())); + } + + /** + * @return string + */ + private function createWorkspaceWithWikiSubmodule(): string + { + $wikiRemote = $this->workspace . '/wiki-remote.git'; + $wikiSeed = $this->workspace . '/wiki-seed'; + $workspace = $this->workspace . '/workspace'; + + mkdir($wikiSeed, 0o777, true); + mkdir($workspace, 0o777, true); + + $this->runProcess(['git', 'init', '--bare', $wikiRemote], $this->workspace); + $this->runProcess(['git', 'init', '--initial-branch=master'], $wikiSeed); + $this->runProcess(['git', 'config', 'user.name', 'Test User'], $wikiSeed); + $this->runProcess(['git', 'config', 'user.email', 'test@example.com'], $wikiSeed); + file_put_contents($wikiSeed . '/README.md', "# Wiki\n"); + $this->runProcess(['git', 'add', 'README.md'], $wikiSeed); + $this->runProcess(['git', 'commit', '-m', 'Seed wiki'], $wikiSeed); + $this->runProcess(['git', 'remote', 'add', 'origin', $wikiRemote], $wikiSeed); + $this->runProcess(['git', 'push', '-u', 'origin', 'master'], $wikiSeed); + $this->runProcess(['git', 'symbolic-ref', 'HEAD', 'refs/heads/master'], $wikiRemote); + + $this->runProcess(['git', 'init', '--initial-branch=main'], $workspace); + $this->runProcess(['git', 'config', 'user.name', 'Test User'], $workspace); + $this->runProcess(['git', 'config', 'user.email', 'test@example.com'], $workspace); + file_put_contents($workspace . '/composer.json', "{\n \"name\": \"fast-forward/dev-tools\"\n}\n"); + $this->runProcess(['git', 'add', 'composer.json'], $workspace); + $this->runProcess(['git', 'commit', '-m', 'Initialize workspace'], $workspace); + $this->runProcess( + [ + 'git', + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + '-b', + 'master', + $wikiRemote, + '.github/wiki', + ], + $workspace, + ); + $this->runProcess(['git', 'commit', '-am', 'Add wiki submodule'], $workspace); + + return $workspace; + } + + /** + * @param bool $shouldChange + * + * @return void + */ + private function createMockDevToolsBinary(bool $shouldChange): void + { + $binDirectory = $this->workspace . '/bin'; + + mkdir($binDirectory, 0o777, true); + + file_put_contents( + $binDirectory . '/dev-tools', + "#!/usr/bin/env bash\nset -euo pipefail\nif [ \"\${1:-}\" != \"wiki\" ]; then\n echo \"unexpected dev-tools arguments: \$*\" >&2\n exit 1\nfi\nif [ \"{$shouldChange}\" = \"1\" ]; then\n printf '# Release wiki refresh\\n' > \"\$PWD/.github/wiki/release-refresh.md\"\nfi\n", + ); + chmod($binDirectory . '/dev-tools', 0o755); + } + + /** + * @param string $workspace + * @param string $outputFile + * + * @return void + */ + private function runAction(string $workspace, string $outputFile): void + { + $process = new Process( + ['bash', self::ACTION_PATH . '/run.sh'], + $workspace, + $this->getIsolatedEnvironment([ + 'GITHUB_OUTPUT' => $outputFile, + 'GIT_AUTHOR_NAME' => 'github-actions[bot]', + 'GIT_AUTHOR_EMAIL' => '41898282+github-actions[bot]@users.noreply.github.com', + 'GIT_ALLOW_PROTOCOL' => 'file:https:http', + 'INPUT_COMMIT_MESSAGE' => 'Refresh wiki docs after release', + 'PATH' => $this->workspace . '/bin:' . getenv('PATH'), + ]), + ); + + $process->mustRun(); + } + + /** + * @param list $command + * @param string $workingDirectory + * + * @return Process + */ + private function runProcess(array $command, string $workingDirectory): Process + { + if (! is_dir($this->workspace . '/home')) { + mkdir($this->workspace . '/home', 0o777, true); + } + + $process = new Process($command, $workingDirectory, $this->getIsolatedEnvironment([ + 'GIT_ALLOW_PROTOCOL' => 'file:https:http', + 'GIT_CONFIG_GLOBAL' => '/dev/null', + 'HOME' => $this->workspace . '/home', + ])); + $process->mustRun(); + + return $process; + } + + /** + * @param array $environment + * + * @return array + */ + private function getIsolatedEnvironment(array $environment): array + { + return $environment + [ + 'GIT_COMMON_DIR' => false, + 'GIT_DIR' => false, + 'GIT_INDEX_FILE' => false, + 'GIT_INTERNAL_SUPER_PREFIX' => false, + 'GIT_PREFIX' => false, + 'GIT_WORK_TREE' => false, + ]; + } + + /** + * @param string $path + * + * @return array + */ + private function parseKeyValueFile(string $path): array + { + if (! is_file($path)) { + return []; + } + + $entries = []; + + foreach (array_filter(explode("\n", trim(file_get_contents($path)))) as $line) { + [$key, $value] = explode('=', $line, 2); + $entries[$key] = $value; + } + + return $entries; + } +} diff --git a/tests/GitHubActions/RetryTransientFailuresActionTest.php b/tests/GitHubActions/RetryTransientFailuresActionTest.php new file mode 100644 index 0000000000..c06ba6d868 --- /dev/null +++ b/tests/GitHubActions/RetryTransientFailuresActionTest.php @@ -0,0 +1,322 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\GitHubActions; + +use FilesystemIterator; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; +use Symfony\Component\Process\Process; + +use function Safe\chmod; +use function Safe\file_get_contents; +use function Safe\file_put_contents; +use function Safe\json_encode; +use function Safe\mkdir; +use function Safe\rmdir; +use function Safe\unlink; + +#[CoversNothing] +final class RetryTransientFailuresActionTest extends TestCase +{ + private const string ACTION_PATH = __DIR__ . '/../../.github/actions/github/retry-transient-failures'; + + private string $workspace; + + /** + * @return void + */ + protected function setUp(): void + { + $this->workspace = sys_get_temp_dir() . '/retry-transient-failures-action-test-' . bin2hex(random_bytes(4)); + mkdir($this->workspace, 0o777, true); + mkdir($this->workspace . '/bin', 0o777, true); + mkdir($this->workspace . '/logs', 0o777, true); + } + + /** + * @return void + */ + protected function tearDown(): void + { + if (! is_dir($this->workspace)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->workspace, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + /** @var SplFileInfo $item */ + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + + continue; + } + + unlink($item->getPathname()); + } + + rmdir($this->workspace); + } + + /** + * @return void + */ + #[Test] + public function actionWillSkipGracefullyWhenAFailedJobLogCannotBeDownloaded(): void + { + $this->writeJobsJson([ + 'jobs' => [ + [ + 'id' => 42, + 'name' => 'maintenance / Publish Wiki Master', + 'conclusion' => 'failure', + ], + ], + ]); + $this->writeLogFixture(42, '401', ''); + $this->createMockExecutables(); + + $outputs = $this->runAction(); + + self::assertSame('skipped-uninspectable-logs', $outputs['status']); + self::assertStringContainsString('maintenance / Publish Wiki Master', $outputs['summary']); + self::assertStringContainsString('401', $outputs['summary']); + self::assertFileDoesNotExist($this->workspace . '/rerun-requested'); + } + + /** + * @return void + */ + #[Test] + public function actionWillRequestARerunWhenEveryFailedJobMatchesATransientSignature(): void + { + $this->writeJobsJson([ + 'jobs' => [ + [ + 'id' => 99, + 'name' => 'Update Wiki Preview', + 'conclusion' => 'failure', + ], + ], + ]); + $this->writeLogFixture( + 99, + '200', + "fatal: unable to access 'https://github.com/php-fast-forward/dev-tools': The requested URL returned error: 500\n" + ); + $this->createMockExecutables(); + + $outputs = $this->runAction(); + + self::assertSame('rerun-requested', $outputs['status']); + self::assertStringContainsString('Update Wiki Preview', $outputs['summary']); + self::assertFileExists($this->workspace . '/rerun-requested'); + } + + /** + * @param array $jobs + * + * @return void + */ + private function writeJobsJson(array $jobs): void + { + file_put_contents( + $this->workspace . '/jobs.json', + json_encode($jobs, \JSON_PRETTY_PRINT | \JSON_THROW_ON_ERROR), + ); + } + + /** + * @param int $jobId + * @param string $statusCode + * @param string $body + * + * @return void + */ + private function writeLogFixture(int $jobId, string $statusCode, string $body): void + { + file_put_contents($this->workspace . '/logs/' . $jobId . '.status', $statusCode); + file_put_contents($this->workspace . '/logs/' . $jobId . '.body', $body); + } + + /** + * @return void + */ + private function createMockExecutables(): void + { + file_put_contents( + $this->workspace . '/bin/gh', + <<<'BASH' + #!/usr/bin/env bash + set -euo pipefail + + if [ "${1:-}" != "api" ]; then + echo "Unexpected gh command: $*" >&2 + exit 1 + fi + + shift + + if [ "${1:-}" = "-X" ]; then + method="${2:-}" + shift 2 + else + method="GET" + fi + + endpoint="${1:-}" + + case "${method}:${endpoint}" in + GET:repos/php-fast-forward/dev-tools/actions/runs/123/jobs?per_page=100) + cat "${MOCK_JOBS_FILE}" + ;; + POST:repos/php-fast-forward/dev-tools/actions/runs/123/rerun-failed-jobs) + touch "${MOCK_RERUN_FILE}" + ;; + *) + echo "Unexpected gh api endpoint: ${method}:${endpoint}" >&2 + exit 1 + ;; + esac + BASH + , + ); + chmod($this->workspace . '/bin/gh', 0o755); + + file_put_contents( + $this->workspace . '/bin/curl', + <<<'BASH' + #!/usr/bin/env bash + set -euo pipefail + + output_file="" + url="" + + while [ "$#" -gt 0 ]; do + case "$1" in + -o) + output_file="$2" + shift 2 + ;; + -w) + shift 2 + ;; + -H) + shift 2 + ;; + -s|-S|-L) + shift + ;; + *) + url="$1" + shift + ;; + esac + done + + job_id="${url##*/actions/jobs/}" + job_id="${job_id%/logs}" + status_file="${MOCK_LOG_DIR}/${job_id}.status" + body_file="${MOCK_LOG_DIR}/${job_id}.body" + + cp "${body_file}" "${output_file}" + cat "${status_file}" + BASH + , + ); + chmod($this->workspace . '/bin/curl', 0o755); + } + + /** + * @return array + */ + private function runAction(): array + { + $outputFile = $this->workspace . '/github-output'; + $process = new Process( + ['bash', self::ACTION_PATH . '/run.sh'], + $this->workspace, + [ + 'GH_TOKEN' => 'test-token', + 'GITHUB_OUTPUT' => $outputFile, + 'GITHUB_REPOSITORY' => 'php-fast-forward/dev-tools', + 'INPUT_MAX_RUN_ATTEMPTS' => '2', + 'INPUT_RUN_ATTEMPT' => '1', + 'INPUT_RUN_ID' => '123', + 'INPUT_WORKFLOW_NAME' => 'Maintain Wiki', + 'MOCK_JOBS_FILE' => $this->workspace . '/jobs.json', + 'MOCK_LOG_DIR' => $this->workspace . '/logs', + 'MOCK_RERUN_FILE' => $this->workspace . '/rerun-requested', + 'PATH' => $this->workspace . '/bin:' . getenv('PATH'), + ], + ); + + $process->mustRun(); + + return $this->parseGitHubOutputFile($outputFile); + } + + /** + * @param string $path + * + * @return array + */ + private function parseGitHubOutputFile(string $path): array + { + if (! is_file($path)) { + return []; + } + + $entries = []; + $lines = explode("\n", trim(file_get_contents($path))); + $counter = \count($lines); + + for ($index = 0; $index < $counter; ++$index) { + $line = $lines[$index]; + + if (str_contains($line, '<<')) { + [$key, $delimiter] = explode('<<', $line, 2); + $value = []; + ++$index; + + while ($index < \count($lines) && $lines[$index] !== $delimiter) { + $value[] = $lines[$index]; + ++$index; + } + + $entries[$key] = implode("\n", $value); + + continue; + } + + [$key, $value] = explode('=', $line, 2); + $entries[$key] = $value; + } + + return $entries; + } +} From 137aaf354fcb13dda6ec513db57d5f01e91feee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 30 Apr 2026 11:19:46 -0300 Subject: [PATCH 2/6] Address workflow review feedback for wiki release handling --- .github/workflows/changelog.yml | 8 ++++++++ .github/workflows/wiki-maintenance.yml | 17 ++++++++--------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 4e0fd7a05d..a3a3baf7fb 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -352,6 +352,13 @@ jobs: pull: "--rebase --autostash" push: true + - 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 @@ -381,6 +388,7 @@ jobs: - 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 }}` diff --git a/.github/workflows/wiki-maintenance.yml b/.github/workflows/wiki-maintenance.yml index d11d61f935..4fb9e863a7 100644 --- a/.github/workflows/wiki-maintenance.yml +++ b/.github/workflows/wiki-maintenance.yml @@ -90,15 +90,14 @@ jobs: runs-on: ubuntu-latest steps: - - name: Explain release publish handling - run: | - { - echo "## Wiki Publish Summary" - echo - echo "- Publish branch: \`master\`" - echo "- Release branch: \`${{ github.event.pull_request.head.ref }}\`" - echo "- Action: skipped preview-branch publication because merged release branches are refreshed from the authoritative released state by \`changelog.yml\`." - } >> "$GITHUB_STEP_SUMMARY" + - uses: ./.dev-tools-actions/.github/actions/summary/write + with: + markdown: | + ## Wiki Publish Summary + + - Publish branch: `master` + - Release branch: `${{ github.event.pull_request.head.ref }}` + - Action: skipped preview-branch publication because merged release branches are refreshed from the authoritative released state by `changelog.yml`. cleanup_closed_preview: name: Delete Closed PR Wiki Preview From c64a2ca75296da0c5ffc2ff8c15fd1d394f662ff Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 06:47:01 +0000 Subject: [PATCH 3/6] Update wiki submodule pointer for PR #310 --- .github/wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/wiki b/.github/wiki index 9cb08e1c2e..c30fa7c65f 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 9cb08e1c2e3410025304ec2a90e50aa8962b84c4 +Subproject commit c30fa7c65f24ea2e7fbfae54d9a4421e7d6b1240 From cad9c63668fd6089e7dd73b1e5fd371de619ba14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 30 Apr 2026 11:29:32 -0300 Subject: [PATCH 4/6] Fix release wiki skip summary without local action checkout --- .github/workflows/wiki-maintenance.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/wiki-maintenance.yml b/.github/workflows/wiki-maintenance.yml index 4fb9e863a7..5442653df6 100644 --- a/.github/workflows/wiki-maintenance.yml +++ b/.github/workflows/wiki-maintenance.yml @@ -90,14 +90,16 @@ jobs: runs-on: ubuntu-latest steps: - - uses: ./.dev-tools-actions/.github/actions/summary/write - with: - markdown: | - ## Wiki Publish Summary - - - Publish branch: `master` - - Release branch: `${{ github.event.pull_request.head.ref }}` - - Action: skipped preview-branch publication because merged release branches are refreshed from the authoritative released state by `changelog.yml`. + - name: Explain release publish handling + env: + RELEASE_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + { + printf '## Wiki Publish Summary\n\n' + printf -- '- Publish branch: `master`\n' + printf -- '- Release branch: `%s`\n' "$RELEASE_BRANCH" + printf -- '- Action: skipped preview-branch publication because merged release branches are refreshed from the authoritative released state by `changelog.yml`.\n' + } >> "$GITHUB_STEP_SUMMARY" cleanup_closed_preview: name: Delete Closed PR Wiki Preview From e9533d858dbf378b853087658aeec584421f56d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 30 Apr 2026 11:42:48 -0300 Subject: [PATCH 5/6] Track wiki pointer drift without republishing content --- .../wiki/refresh-release-pointer/run.sh | 8 ++++- .../RefreshReleaseWikiPointerActionTest.php | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/actions/wiki/refresh-release-pointer/run.sh b/.github/actions/wiki/refresh-release-pointer/run.sh index 9d8d6e0a87..28a4758652 100755 --- a/.github/actions/wiki/refresh-release-pointer/run.sh +++ b/.github/actions/wiki/refresh-release-pointer/run.sh @@ -20,9 +20,15 @@ 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=false" + echo "pointer-changed=${pointer_changed}" echo "publish-sha=$(git -C "${target}" rev-parse HEAD)" } >> "${GITHUB_OUTPUT}" diff --git a/tests/GitHubActions/RefreshReleaseWikiPointerActionTest.php b/tests/GitHubActions/RefreshReleaseWikiPointerActionTest.php index fbcab13056..2a12db4000 100644 --- a/tests/GitHubActions/RefreshReleaseWikiPointerActionTest.php +++ b/tests/GitHubActions/RefreshReleaseWikiPointerActionTest.php @@ -119,6 +119,27 @@ public function refreshWillSkipPublicationWhenTheRenderedWikiDoesNotChange(): vo self::assertSame('', trim($status->getOutput())); } + /** + * @return void + */ + #[Test] + public function refreshWillReportPointerDriftWhenTheWikiRemoteAdvancedWithoutNewRenderedChanges(): void + { + $workspace = $this->createWorkspaceWithWikiSubmodule(); + $this->advanceWikiRemote(); + $this->createMockDevToolsBinary(false); + $outputFile = $this->workspace . '/github-output'; + + $this->runAction($workspace, $outputFile); + + $outputs = $this->parseKeyValueFile($outputFile); + $status = $this->runProcess(['git', 'status', '--short', '.github/wiki'], $workspace); + + self::assertSame('false', $outputs['published']); + self::assertSame('true', $outputs['pointer-changed']); + self::assertStringContainsString('.github/wiki', $status->getOutput()); + } + /** * @return string */ @@ -185,6 +206,19 @@ private function createMockDevToolsBinary(bool $shouldChange): void chmod($binDirectory . '/dev-tools', 0o755); } + /** + * @return void + */ + private function advanceWikiRemote(): void + { + $wikiSeed = $this->workspace . '/wiki-seed'; + + file_put_contents($wikiSeed . '/README.md', "# Wiki\n\nUpdated upstream.\n"); + $this->runProcess(['git', 'add', 'README.md'], $wikiSeed); + $this->runProcess(['git', 'commit', '-m', 'Advance wiki remote'], $wikiSeed); + $this->runProcess(['git', 'push', 'origin', 'master'], $wikiSeed); + } + /** * @param string $workspace * @param string $outputFile From f9b83f869a7699e81309954dab69fca0f87316e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Thu, 30 Apr 2026 14:09:35 -0300 Subject: [PATCH 6/6] Clean up release wiki preview branches on merge --- .github/workflows/wiki-maintenance.yml | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/wiki-maintenance.yml b/.github/workflows/wiki-maintenance.yml index 5442653df6..0ed2bfdae0 100644 --- a/.github/workflows/wiki-maintenance.yml +++ b/.github/workflows/wiki-maintenance.yml @@ -88,8 +88,35 @@ jobs: name: Skip Release Branch Wiki Publish if: github.event_name == 'pull_request_target' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && startsWith(github.event.pull_request.head.ref, inputs.release-branch-prefix) runs-on: ubuntu-latest + permissions: + contents: write + + env: + WIKI_PREVIEW_BRANCH: pr-${{ github.event.pull_request.number }} steps: + - name: Checkout main branch + uses: actions/checkout@v6 + with: + token: ${{ github.token }} + ref: main + submodules: recursive + fetch-depth: 0 + + - name: Mark wiki workspace as safe for git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE/.github/wiki" + + - name: Delete wiki preview branch + id: cleanup_preview + working-directory: .github/wiki + run: | + if git ls-remote --exit-code --heads origin "${WIKI_PREVIEW_BRANCH}" >/dev/null 2>&1; then + git push origin --delete "${WIKI_PREVIEW_BRANCH}" + echo "deleted=true" >> "$GITHUB_OUTPUT" + else + echo "deleted=false" >> "$GITHUB_OUTPUT" + fi + - name: Explain release publish handling env: RELEASE_BRANCH: ${{ github.event.pull_request.head.ref }} @@ -97,8 +124,10 @@ jobs: { printf '## Wiki Publish Summary\n\n' printf -- '- Publish branch: `master`\n' + printf -- '- Preview branch: `%s`\n' "${WIKI_PREVIEW_BRANCH}" printf -- '- Release branch: `%s`\n' "$RELEASE_BRANCH" printf -- '- Action: skipped preview-branch publication because merged release branches are refreshed from the authoritative released state by `changelog.yml`.\n' + printf -- '- Preview cleanup: `%s`\n' "${{ steps.cleanup_preview.outputs.deleted == 'true' && 'deleted immediately' || 'not present' }}" } >> "$GITHUB_STEP_SUMMARY" cleanup_closed_preview: