diff --git a/.github/actions/changelog/create-dependabot-entry/action.yml b/.github/actions/changelog/create-dependabot-entry/action.yml new file mode 100644 index 0000000..ccb4a8e --- /dev/null +++ b/.github/actions/changelog/create-dependabot-entry/action.yml @@ -0,0 +1,44 @@ +name: Create Dependabot Changelog Entry +description: Create and push a minimal changelog entry for a same-repository Dependabot pull request when the branch still lacks one. + +inputs: + changelog-file: + description: Managed changelog path. + required: true + base-ref: + description: Base branch reference used for changelog comparison. + required: true + head-ref: + description: Pull request head branch ref. + required: true + pull-request-number: + description: Pull request number. + required: true + pull-request-title: + description: Pull request title used as the fallback changelog message source. + required: true + +outputs: + created: + description: Whether a changelog entry was created and pushed. + value: ${{ steps.create.outputs.created }} + status: + description: Whether the branch already had an entry, auto-created one, or still remains missing. + value: ${{ steps.create.outputs.status }} + message: + description: Generated changelog entry message, or empty when no entry was needed. + value: ${{ steps.create.outputs.message }} + +runs: + using: composite + steps: + - id: create + name: Create entry when missing + shell: bash + env: + INPUT_CHANGELOG_FILE: ${{ inputs.changelog-file }} + INPUT_BASE_REF: ${{ inputs.base-ref }} + INPUT_HEAD_REF: ${{ inputs.head-ref }} + INPUT_PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }} + INPUT_PULL_REQUEST_TITLE: ${{ inputs.pull-request-title }} + run: ${{ github.action_path }}/run.sh diff --git a/.github/actions/changelog/create-dependabot-entry/run.sh b/.github/actions/changelog/create-dependabot-entry/run.sh new file mode 100755 index 0000000..fd44d0e --- /dev/null +++ b/.github/actions/changelog/create-dependabot-entry/run.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +entry_message="$(php <<'PHP' +/dev/null 2>&1; then + { + echo "created=false" + echo "status=already-present" + printf 'message=%s\n' "${entry_message}" + } >> "$GITHUB_OUTPUT" + + exit 0 +fi + +"${dev_tools_bin}" changelog:entry --type=changed --file="${INPUT_CHANGELOG_FILE}" "${entry_message}" +git add "${INPUT_CHANGELOG_FILE}" + +if git diff --cached --quiet -- "${INPUT_CHANGELOG_FILE}"; then + { + echo "created=false" + echo "status=missing" + printf 'message=%s\n' "${entry_message}" + } >> "$GITHUB_OUTPUT" + + exit 1 +fi + +git commit -m "Add changelog entry for Dependabot PR #${INPUT_PULL_REQUEST_NUMBER}" +git push origin "HEAD:${INPUT_HEAD_REF}" + +if ! "${dev_tools_bin}" changelog:check --file="${INPUT_CHANGELOG_FILE}" --against="origin/${INPUT_BASE_REF}" >/dev/null 2>&1; then + { + echo "created=false" + echo "status=missing" + printf 'message=%s\n' "${entry_message}" + } >> "$GITHUB_OUTPUT" + + exit 1 +fi + +if ! grep -F --quiet -- "- ${entry_message}" "${INPUT_CHANGELOG_FILE}"; then + { + echo "created=false" + echo "status=missing" + printf 'message=%s\n' "${entry_message}" + } >> "$GITHUB_OUTPUT" + + exit 1 +fi + +{ + echo "created=true" + echo "status=auto-created" + printf 'message=%s\n' "${entry_message}" +} >> "$GITHUB_OUTPUT" diff --git a/.github/actions/changelog/publish-release/action.yml b/.github/actions/changelog/publish-release/action.yml new file mode 100644 index 0000000..1e3738f --- /dev/null +++ b/.github/actions/changelog/publish-release/action.yml @@ -0,0 +1,35 @@ +name: Publish GitHub Release +description: Create or update a GitHub release from a rendered release-notes file. + +inputs: + version: + description: Release version without leading v. + required: true + target: + description: Commit SHA or ref the release should target. + required: true + notes-file: + description: Rendered release-notes file path. + required: false + default: .dev-tools/release-notes.md + +runs: + using: composite + steps: + - id: publish + name: Publish release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_TARGET: ${{ inputs.target }} + INPUT_NOTES_FILE: ${{ inputs.notes-file }} + run: ${{ github.action_path }}/run.sh + +outputs: + operation: + description: Whether the release was created or updated. + value: ${{ steps.publish.outputs.operation }} + url: + description: URL of the published GitHub release. + value: ${{ steps.publish.outputs.url }} diff --git a/.github/actions/changelog/publish-release/run.sh b/.github/actions/changelog/publish-release/run.sh new file mode 100755 index 0000000..0fa2ea3 --- /dev/null +++ b/.github/actions/changelog/publish-release/run.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +release_tag="v${INPUT_VERSION}" + +if gh release view "${release_tag}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + operation="updated" + gh release edit "${release_tag}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "${release_tag}" \ + --notes-file "${INPUT_NOTES_FILE}" +else + operation="created" + gh release create "${release_tag}" \ + --repo "${GITHUB_REPOSITORY}" \ + --target "${INPUT_TARGET}" \ + --title "${release_tag}" \ + --notes-file "${INPUT_NOTES_FILE}" +fi + +release_url="$(gh release view "${release_tag}" --repo "${GITHUB_REPOSITORY}" --json url --jq '.url')" + +echo "operation=${operation}" >> "${GITHUB_OUTPUT}" +echo "url=${release_url}" >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/changelog/render-release-notes/action.yml b/.github/actions/changelog/render-release-notes/action.yml new file mode 100644 index 0000000..921ab3c --- /dev/null +++ b/.github/actions/changelog/render-release-notes/action.yml @@ -0,0 +1,25 @@ +name: Render Release Notes +description: Render a changelog section into a release-notes file. + +inputs: + changelog-file: + description: Managed changelog path. + required: true + version: + description: Release version to render. + required: true + output-file: + description: Output file path for rendered release notes. + required: false + default: .dev-tools/release-notes.md + +runs: + using: composite + steps: + - name: Render release notes + shell: bash + env: + INPUT_CHANGELOG_FILE: ${{ inputs.changelog-file }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_OUTPUT_FILE: ${{ inputs.output-file }} + run: ${{ github.action_path }}/run.sh diff --git a/.github/actions/changelog/render-release-notes/run.sh b/.github/actions/changelog/render-release-notes/run.sh new file mode 100755 index 0000000..d74b5bb --- /dev/null +++ b/.github/actions/changelog/render-release-notes/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +dev_tools_bin="${DEV_TOOLS_BIN:-dev-tools}" + +mkdir -p "$(dirname "${INPUT_OUTPUT_FILE}")" +"${dev_tools_bin}" changelog:show "${INPUT_VERSION}" --file="${INPUT_CHANGELOG_FILE}" > "${INPUT_OUTPUT_FILE}" diff --git a/.github/actions/changelog/resolve-merged-version/action.yml b/.github/actions/changelog/resolve-merged-version/action.yml new file mode 100644 index 0000000..d4b3f22 --- /dev/null +++ b/.github/actions/changelog/resolve-merged-version/action.yml @@ -0,0 +1,26 @@ +name: Resolve Merged Release Version +description: Derive the released version from a merged release branch name. + +inputs: + head-ref: + description: Pull request head ref. + required: true + release-branch-prefix: + description: Release branch prefix. + required: true + +outputs: + value: + description: Resolved release version. + value: ${{ steps.resolve.outputs.value }} + +runs: + using: composite + steps: + - id: resolve + name: Resolve merged version + shell: bash + env: + INPUT_HEAD_REF: ${{ inputs.head-ref }} + INPUT_RELEASE_BRANCH_PREFIX: ${{ inputs.release-branch-prefix }} + run: ${{ github.action_path }}/run.sh diff --git a/.github/actions/changelog/resolve-merged-version/run.sh b/.github/actions/changelog/resolve-merged-version/run.sh new file mode 100755 index 0000000..d9e9abb --- /dev/null +++ b/.github/actions/changelog/resolve-merged-version/run.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +version="${INPUT_HEAD_REF#${INPUT_RELEASE_BRANCH_PREFIX}}" + +if [ -z "${version}" ] || [ "${version}" = "${INPUT_HEAD_REF}" ]; then + echo "Failed to derive the release version from ${INPUT_HEAD_REF}." >&2 + exit 1 +fi + +echo "value=${version}" >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/changelog/resolve-version/action.yml b/.github/actions/changelog/resolve-version/action.yml new file mode 100644 index 0000000..6b750a2 --- /dev/null +++ b/.github/actions/changelog/resolve-version/action.yml @@ -0,0 +1,30 @@ +name: Resolve Changelog Release Version +description: Resolve the release version from workflow input or infer it from the changelog. + +inputs: + changelog-file: + description: Managed changelog path. + required: true + version: + description: Optional explicit version. + required: false + default: '' + +outputs: + value: + description: Resolved release version. + value: ${{ steps.resolve.outputs.value }} + source: + description: Resolution source, either input or inferred. + value: ${{ steps.resolve.outputs.source }} + +runs: + using: composite + steps: + - id: resolve + name: Resolve version + shell: bash + env: + INPUT_CHANGELOG_FILE: ${{ inputs.changelog-file }} + INPUT_VERSION: ${{ inputs.version }} + run: ${{ github.action_path }}/run.sh diff --git a/.github/actions/changelog/resolve-version/run.sh b/.github/actions/changelog/resolve-version/run.sh new file mode 100755 index 0000000..e62b46f --- /dev/null +++ b/.github/actions/changelog/resolve-version/run.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +dev_tools_bin="${DEV_TOOLS_BIN:-dev-tools}" + +if [ -n "${INPUT_VERSION}" ]; then + version="${INPUT_VERSION}" + source="input" +else + version="$("${dev_tools_bin}" changelog:next-version --file="${INPUT_CHANGELOG_FILE}")" + source="inferred" +fi + +echo "value=${version}" >> "${GITHUB_OUTPUT}" +echo "source=${source}" >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/dev-tools/setup/action.yml b/.github/actions/dev-tools/setup/action.yml new file mode 100644 index 0000000..7691a19 --- /dev/null +++ b/.github/actions/dev-tools/setup/action.yml @@ -0,0 +1,91 @@ +name: Setup Fast Forward DevTools +description: Resolve a local dev-tools binary or install fast-forward/dev-tools globally. + +inputs: + version: + description: Composer version constraint or branch for fast-forward/dev-tools. + required: false + default: ^1.0 + repository: + description: Composer VCS repository used when installing branch refs. + required: false + default: https://github.com/php-fast-forward/dev-tools + prefer-local: + description: Use a project-local vendor/bin/dev-tools binary when available. + required: false + default: 'true' + composer-home: + description: COMPOSER_HOME used for global installs. + required: false + default: '' + cache-dir: + description: Composer cache directory used for global installs. + required: false + default: /tmp/composer-cache + +outputs: + command: + description: Absolute dev-tools command path. + value: ${{ steps.resolve.outputs.command }} + source: + description: Where the command was resolved from. + value: ${{ steps.resolve.outputs.source }} + +runs: + using: composite + steps: + - name: Resolve or install dev-tools + id: resolve + shell: bash + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"} }' + INPUT_CACHE_DIR: ${{ inputs.cache-dir }} + INPUT_COMPOSER_HOME: ${{ inputs.composer-home }} + INPUT_PREFER_LOCAL: ${{ inputs.prefer-local }} + INPUT_REPOSITORY: ${{ inputs.repository }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + command_path='' + command_source='' + + if [ "${INPUT_PREFER_LOCAL}" = "true" ] && [ -x "${GITHUB_WORKSPACE}/vendor/bin/dev-tools" ]; then + command_path="${GITHUB_WORKSPACE}/vendor/bin/dev-tools" + command_source='project' + elif command -v dev-tools >/dev/null 2>&1; then + command_path="$(command -v dev-tools)" + command_source='path' + fi + + if [ -z "${command_path}" ]; then + composer_home="${INPUT_COMPOSER_HOME:-${RUNNER_TEMP}/fast-forward-composer-home}" + mkdir -p "${composer_home}" "${INPUT_CACHE_DIR}" + + export COMPOSER_HOME="${composer_home}" + export COMPOSER_CACHE_DIR="${INPUT_CACHE_DIR}" + + composer global config --no-plugins allow-plugins.fast-forward/dev-tools true + composer global config --no-plugins allow-plugins.ergebnis/composer-normalize true + composer global config --no-plugins allow-plugins.phpdocumentor/shim true + composer global config --no-plugins allow-plugins.phpro/grumphp-shim true + composer global config --no-plugins allow-plugins.pyrech/composer-changelogs true + composer global config --no-plugins repositories.fast-forward-dev-tools vcs "${INPUT_REPOSITORY}" + composer global require --no-interaction --prefer-dist --no-progress --no-scripts "fast-forward/dev-tools:${INPUT_VERSION}" + + command_path="${composer_home}/vendor/bin/dev-tools" + command_source='composer-global' + + echo "${composer_home}/vendor/bin" >> "${GITHUB_PATH}" + echo "COMPOSER_HOME=${composer_home}" >> "${GITHUB_ENV}" + fi + + if [ ! -x "${command_path}" ]; then + echo "::error::Resolved dev-tools command is not executable: ${command_path}" + exit 1 + fi + + echo "DEV_TOOLS_BIN=${command_path}" >> "${GITHUB_ENV}" + echo "command=${command_path}" >> "${GITHUB_OUTPUT}" + echo "source=${command_source}" >> "${GITHUB_OUTPUT}" + "${command_path}" --version diff --git a/.github/actions/github-actions/setup/action.yml b/.github/actions/github-actions/setup/action.yml new file mode 100644 index 0000000..2f950af --- /dev/null +++ b/.github/actions/github-actions/setup/action.yml @@ -0,0 +1,86 @@ +name: Setup Fast Forward GitHub Actions Runtime +description: Resolve or globally install the fast-forward/github-actions command runtime. + +inputs: + version: + description: Composer version constraint or branch for fast-forward/github-actions. + required: false + default: dev-main + repository: + description: Composer VCS repository used when installing branch refs. + required: false + default: https://github.com/php-fast-forward/github-actions + prefer-local: + description: Use a project-local vendor/bin/fast-forward-actions binary when available. + required: false + default: 'true' + composer-home: + description: COMPOSER_HOME used for global installs. + required: false + default: '' + cache-dir: + description: Composer cache directory used for global installs. + required: false + default: /tmp/composer-cache + +outputs: + command: + description: Absolute fast-forward-actions command path. + value: ${{ steps.resolve.outputs.command }} + source: + description: Where the command was resolved from. + value: ${{ steps.resolve.outputs.source }} + +runs: + using: composite + steps: + - name: Resolve or install Fast Forward GitHub Actions runtime + id: resolve + shell: bash + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"} }' + INPUT_CACHE_DIR: ${{ inputs.cache-dir }} + INPUT_COMPOSER_HOME: ${{ inputs.composer-home }} + INPUT_PREFER_LOCAL: ${{ inputs.prefer-local }} + INPUT_REPOSITORY: ${{ inputs.repository }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + command_path='' + command_source='' + + if [ "${INPUT_PREFER_LOCAL}" = "true" ] && [ -x "${GITHUB_WORKSPACE}/vendor/bin/fast-forward-actions" ]; then + command_path="${GITHUB_WORKSPACE}/vendor/bin/fast-forward-actions" + command_source='project' + elif command -v fast-forward-actions >/dev/null 2>&1; then + command_path="$(command -v fast-forward-actions)" + command_source='path' + fi + + if [ -z "${command_path}" ]; then + composer_home="${INPUT_COMPOSER_HOME:-${RUNNER_TEMP}/fast-forward-composer-home}" + mkdir -p "${composer_home}" "${INPUT_CACHE_DIR}" + + export COMPOSER_HOME="${composer_home}" + export COMPOSER_CACHE_DIR="${INPUT_CACHE_DIR}" + + composer global config --no-plugins repositories.fast-forward-github-actions vcs "${INPUT_REPOSITORY}" + composer global require --no-plugins --no-scripts --no-interaction --prefer-dist --no-progress "fast-forward/github-actions:${INPUT_VERSION}" + + command_path="${composer_home}/vendor/bin/fast-forward-actions" + command_source='composer-global' + + echo "${composer_home}/vendor/bin" >> "${GITHUB_PATH}" + echo "COMPOSER_HOME=${composer_home}" >> "${GITHUB_ENV}" + fi + + if [ ! -x "${command_path}" ]; then + echo "::error::Resolved fast-forward-actions command is not executable: ${command_path}" + exit 1 + fi + + echo "FAST_FORWARD_ACTIONS_BIN=${command_path}" >> "${GITHUB_ENV}" + echo "command=${command_path}" >> "${GITHUB_OUTPUT}" + echo "source=${command_source}" >> "${GITHUB_OUTPUT}" + "${command_path}" --version diff --git a/.github/actions/github-pages/cleanup-orphaned-previews/action.yml b/.github/actions/github-pages/cleanup-orphaned-previews/action.yml new file mode 100644 index 0000000..11f5068 --- /dev/null +++ b/.github/actions/github-pages/cleanup-orphaned-previews/action.yml @@ -0,0 +1,29 @@ +name: Cleanup Orphaned GitHub Pages Previews +description: Remove preview directories for pull requests that are no longer open. + +inputs: + path: + description: Checked-out gh-pages working tree path. + required: true + +runs: + using: composite + steps: + - id: cleanup + name: Cleanup orphaned previews + shell: bash + env: + INPUT_PATH: ${{ inputs.path }} + GH_TOKEN: ${{ github.token }} + run: ${{ github.action_path }}/run.sh + +outputs: + deleted: + description: Number of deleted preview directories. + value: ${{ steps.cleanup.outputs.deleted }} + skipped: + description: Number of retained preview directories. + value: ${{ steps.cleanup.outputs.skipped }} + unresolved: + description: Number of unresolved preview directories. + value: ${{ steps.cleanup.outputs.unresolved }} diff --git a/.github/actions/github-pages/cleanup-orphaned-previews/run.sh b/.github/actions/github-pages/cleanup-orphaned-previews/run.sh new file mode 100755 index 0000000..370dc10 --- /dev/null +++ b/.github/actions/github-pages/cleanup-orphaned-previews/run.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +pages_path="${INPUT_PATH}" + +cd "${pages_path}" + +deleted=0 +skipped=0 +unresolved=0 + +if [ ! -d previews ]; then + echo "No previews directory exists. Nothing to clean." + + exit 0 +fi + +while read -r preview_dir; do + branch="${preview_dir#previews/}" + pull_request_number="${branch#pr-}" + + if ! [[ "${pull_request_number}" =~ ^[0-9]+$ ]]; then + echo "Skipping preview directory ${preview_dir}: name does not match pr-." + skipped=$((skipped + 1)) + continue + fi + + state="$(gh pr view "${pull_request_number}" --repo "${GITHUB_REPOSITORY}" --json state --jq '.state' 2>/dev/null || echo UNKNOWN)" + + case "${state}" in + CLOSED|MERGED) + echo "Deleting preview directory ${preview_dir} for ${state} pull request #${pull_request_number}." + rm -rf "${preview_dir}" + deleted=$((deleted + 1)) + ;; + OPEN) + echo "Keeping preview directory ${preview_dir} for open pull request #${pull_request_number}." + skipped=$((skipped + 1)) + ;; + *) + echo "Could not resolve pull request #${pull_request_number} for preview directory ${preview_dir}. Keeping it." + unresolved=$((unresolved + 1)) + ;; + esac +done < <(find previews -mindepth 1 -maxdepth 1 -type d -name 'pr-*' | sort) + +echo "Preview cleanup summary: deleted=${deleted}, skipped=${skipped}, unresolved=${unresolved}." + +echo "deleted=${deleted}" >> "${GITHUB_OUTPUT}" +echo "skipped=${skipped}" >> "${GITHUB_OUTPUT}" +echo "unresolved=${unresolved}" >> "${GITHUB_OUTPUT}" + +touch .nojekyll +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" +git add -A +git diff --cached --quiet || git commit -m "chore: remove orphaned pull request previews" diff --git a/.github/actions/github-pages/remove-preview/action.yml b/.github/actions/github-pages/remove-preview/action.yml new file mode 100644 index 0000000..7337ee2 --- /dev/null +++ b/.github/actions/github-pages/remove-preview/action.yml @@ -0,0 +1,20 @@ +name: Remove GitHub Pages Preview +description: Remove a pull-request preview directory from a checked-out gh-pages tree and commit the change when needed. + +inputs: + path: + description: Checked-out gh-pages working tree path. + required: true + pull-request-number: + description: Pull request number whose preview should be removed. + required: true + +runs: + using: composite + steps: + - name: Remove preview + shell: bash + env: + INPUT_PATH: ${{ inputs.path }} + INPUT_PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }} + run: ${{ github.action_path }}/run.sh diff --git a/.github/actions/github-pages/remove-preview/run.sh b/.github/actions/github-pages/remove-preview/run.sh new file mode 100755 index 0000000..ae0f630 --- /dev/null +++ b/.github/actions/github-pages/remove-preview/run.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +pages_path="${INPUT_PATH}" +pull_request_number="${INPUT_PULL_REQUEST_NUMBER}" + +rm -rf "${pages_path}/previews/pr-${pull_request_number}" + +cd "${pages_path}" +touch .nojekyll +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" +git add -A +git diff --cached --quiet || git commit -m "chore: remove preview for PR #${pull_request_number}" diff --git a/.github/actions/github-pages/restore-previews/action.yml b/.github/actions/github-pages/restore-previews/action.yml new file mode 100644 index 0000000..1060f81 --- /dev/null +++ b/.github/actions/github-pages/restore-previews/action.yml @@ -0,0 +1,20 @@ +name: Restore GitHub Pages Previews +description: Copy existing preview directories from a checked-out gh-pages tree into the new publish target. + +inputs: + source: + description: Checked-out gh-pages directory that may contain previews. + required: true + target: + description: Publish target directory that should receive existing previews. + required: true + +runs: + using: composite + steps: + - name: Restore previews + shell: bash + env: + INPUT_SOURCE: ${{ inputs.source }} + INPUT_TARGET: ${{ inputs.target }} + run: ${{ github.action_path }}/run.sh diff --git a/.github/actions/github-pages/restore-previews/run.sh b/.github/actions/github-pages/restore-previews/run.sh new file mode 100755 index 0000000..959a2ec --- /dev/null +++ b/.github/actions/github-pages/restore-previews/run.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +source_dir="${INPUT_SOURCE}" +target_dir="${INPUT_TARGET}" + +if [ -d "${source_dir}/previews" ]; then + mkdir -p "${target_dir}/previews" + cp -R "${source_dir}/previews/." "${target_dir}/previews/" +fi diff --git a/.github/actions/github-pages/verify-deployment/action.yml b/.github/actions/github-pages/verify-deployment/action.yml new file mode 100644 index 0000000..227f109 --- /dev/null +++ b/.github/actions/github-pages/verify-deployment/action.yml @@ -0,0 +1,34 @@ +name: Verify GitHub Pages Deployment +description: Health-check a set of GitHub Pages URLs with retry/backoff. + +inputs: + base-url: + description: Base URL of the deployed site. + required: true + title: + description: Annotation title used when a check fails. + required: true + checks: + description: Newline-separated list of relative_path|label entries. + required: true + attempts: + description: Number of retry attempts. + required: false + default: '6' + delay: + description: Delay in seconds between retries. + required: false + default: '5' + +runs: + using: composite + steps: + - name: Verify deployment + shell: bash + env: + INPUT_BASE_URL: ${{ inputs.base-url }} + INPUT_TITLE: ${{ inputs.title }} + INPUT_CHECKS: ${{ inputs.checks }} + INPUT_ATTEMPTS: ${{ inputs.attempts }} + INPUT_DELAY: ${{ inputs.delay }} + run: ${{ github.action_path }}/run.sh diff --git a/.github/actions/github-pages/verify-deployment/run.sh b/.github/actions/github-pages/verify-deployment/run.sh new file mode 100755 index 0000000..9823c99 --- /dev/null +++ b/.github/actions/github-pages/verify-deployment/run.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +base_url="${INPUT_BASE_URL}" +title="${INPUT_TITLE}" +attempts="${INPUT_ATTEMPTS}" +delay="${INPUT_DELAY}" + +check_url() { + url="$1" + label="$2" + + for attempt in $(seq 1 "${attempts}"); do + echo "Checking ${label}: ${url} (attempt ${attempt}/${attempts})" + + http_status="$(curl --silent --show-error --output /dev/null --location --write-out '%{http_code}' "${url}" 2>/tmp/curl-error.log || true)" + + if [ "${http_status}" -ge 200 ] && [ "${http_status}" -lt 400 ]; then + echo "${label} is reachable (${http_status})." + + return 0 + fi + + curl_error="$(cat /tmp/curl-error.log)" + + if [ -n "${curl_error}" ]; then + echo "Attempt ${attempt} failed for ${label}: ${url} (curl error: ${curl_error})" + else + echo "Attempt ${attempt} failed for ${label}: ${url} (HTTP ${http_status})" + fi + + if [ "${attempt}" -lt "${attempts}" ]; then + sleep "${delay}" + fi + done + + echo "::error title=${title}::${label} is not reachable at ${url}. Last HTTP status: ${http_status}${curl_error:+; curl error: ${curl_error}}" + + return 1 +} + +while IFS='|' read -r relative_path label; do + if [ -z "${label}" ]; then + continue + fi + + check_url "${base_url}${relative_path}" "${label}" +done < --source= --output= [--repository-url=]\n"); + + exit(2); +} + +$targetContents = file_get_contents($target); +$sourceContents = file_get_contents($source); + +if (! is_string($targetContents) || ! is_string($sourceContents)) { + fwrite(STDERR, "Unable to read changelog conflict stages.\n"); + + exit(2); +} + +$resolver = new UnreleasedChangelogConflictResolver(new ChangelogParser(), new MarkdownRenderer()); +$resolved = $resolver->resolve($targetContents, [$sourceContents], $repositoryUrl); + +file_put_contents($output, $resolved); diff --git a/.github/actions/github/resolve-predictable-conflicts/run.sh b/.github/actions/github/resolve-predictable-conflicts/run.sh new file mode 100755 index 0000000..519ca3e --- /dev/null +++ b/.github/actions/github/resolve-predictable-conflicts/run.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +set -euo pipefail + +base_ref="${INPUT_BASE_REF:-main}" +pull_request_number="${INPUT_PULL_REQUEST_NUMBER:-}" +allowed_conflicts=$'CHANGELOG.md\n.github/wiki' +resolved_count=0 +skipped_count=0 +failed_count=0 +summary_file="${GITHUB_STEP_SUMMARY:-}" + +append_summary() { + local message="$1" + + if [ -n "${summary_file}" ]; then + printf '%s\n' "${message}" >> "${summary_file}" + else + printf '%s\n' "${message}" + fi +} + +collect_pull_requests() { + if [ -n "${pull_request_number}" ]; then + gh pr view "${pull_request_number}" \ + --json number,title,url,baseRefName,headRefName,headRepositoryOwner,isCrossRepository,mergeable + + return + fi + + gh pr list \ + --state open \ + --base "${base_ref}" \ + --json number,title,url,baseRefName,headRefName,headRepositoryOwner,isCrossRepository,mergeable +} + +repository_url() { + php -r ' + $composer = json_decode((string) file_get_contents("composer.json"), true); + $support = is_array($composer) ? ($composer["support"] ?? []) : []; + $source = is_array($support) ? ($support["source"] ?? null) : null; + echo is_string($source) && "" !== $source ? $source : "https://github.com/" . getenv("GITHUB_REPOSITORY"); + ' +} + +is_allowed_conflict_scope() { + local conflicts="$1" + + while IFS= read -r file; do + if [ -z "${file}" ]; then + continue + fi + + if ! grep -Fx --quiet -- "${file}" <<< "${allowed_conflicts}"; then + return 1 + fi + done <<< "${conflicts}" + + return 0 +} + +dispatch_required_tests() { + local head_ref="$1" + + if ! gh workflow view tests.yml >/dev/null 2>&1; then + append_summary " - tests dispatch skipped: tests.yml workflow was not found" + + return 0 + fi + + if gh workflow run tests.yml --ref "${head_ref}" -f publish-required-statuses=true >/dev/null 2>&1; then + append_summary " - tests dispatch requested with required status mirroring" + + return 0 + fi + + if gh workflow run tests.yml --ref "${head_ref}" >/dev/null 2>&1; then + append_summary " - tests dispatch requested without required status mirroring" + + return 0 + fi + + append_summary " - failed: resolved branch was pushed, but tests.yml could not be dispatched" + + return 1 +} + +resolve_pull_request() { + local number="$1" + local title="$2" + local url="$3" + local head_ref="$4" + local head_owner="$5" + local cross_repository="$6" + local pr_base_ref="$7" + local mergeable="$8" + + append_summary "- PR #${number}: inspecting ${url}" + + if [ "${pr_base_ref}" != "${base_ref}" ]; then + append_summary " - skipped: base branch is \`${pr_base_ref}\`, expected \`${base_ref}\`" + skipped_count=$((skipped_count + 1)) + + return + fi + + if [ "${cross_repository}" = "true" ] || [ "${head_owner}" != "${GITHUB_REPOSITORY_OWNER}" ]; then + append_summary " - skipped: pull request branch is outside this repository" + skipped_count=$((skipped_count + 1)) + + return + fi + + if [ "${mergeable}" = "MERGEABLE" ]; then + append_summary " - skipped: GitHub currently reports the pull request as mergeable" + skipped_count=$((skipped_count + 1)) + + return + fi + + local workdir + workdir="$(mktemp -d)" + trap 'rm -rf "${workdir}"' RETURN + + git clone --no-tags "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${workdir}/repo" >/dev/null 2>&1 + git -C "${workdir}/repo" config user.name "github-actions[bot]" + git -C "${workdir}/repo" config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git -C "${workdir}/repo" fetch --no-tags origin \ + "+refs/heads/${base_ref}:refs/remotes/origin/${base_ref}" \ + "+refs/heads/${head_ref}:refs/remotes/origin/${head_ref}" >/dev/null 2>&1 + git -C "${workdir}/repo" switch -C "${head_ref}" "refs/remotes/origin/${head_ref}" >/dev/null 2>&1 + + if git -C "${workdir}/repo" merge --no-commit --no-ff "refs/remotes/origin/${base_ref}" >/dev/null 2>&1; then + git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true + append_summary " - skipped: merge succeeds cleanly when checked locally" + skipped_count=$((skipped_count + 1)) + + return + fi + + local conflicts + conflicts="$(git -C "${workdir}/repo" diff --name-only --diff-filter=U)" + + if [ -z "${conflicts}" ]; then + git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true + append_summary " - skipped: merge failed but no unmerged files were reported" + skipped_count=$((skipped_count + 1)) + + return + fi + + if ! is_allowed_conflict_scope "${conflicts}"; then + git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true + append_summary " - skipped: conflict scope requires manual review" + append_summary "$(printf '%s\n' "${conflicts}" | sed 's/^/ - `/; s/$/`/')" + skipped_count=$((skipped_count + 1)) + + return + fi + + if grep -Fx --quiet -- ".github/wiki" <<< "${conflicts}"; then + git -C "${workdir}/repo" checkout --ours -- .github/wiki + git -C "${workdir}/repo" add .github/wiki + fi + + if grep -Fx --quiet -- "CHANGELOG.md" <<< "${conflicts}"; then + # During `git merge base into PR`, stage 2 is the PR side and stage 3 is the base branch side. + git -C "${workdir}/repo" show :2:CHANGELOG.md > "${workdir}/CHANGELOG.ours.md" + git -C "${workdir}/repo" show :3:CHANGELOG.md > "${workdir}/CHANGELOG.theirs.md" + ( + cd "${workdir}/repo" + php "${DEV_TOOLS_CONFLICT_RESOLVER}" \ + --target="${workdir}/CHANGELOG.theirs.md" \ + --source="${workdir}/CHANGELOG.ours.md" \ + --output="CHANGELOG.md" \ + --repository-url="$(repository_url)" + ) + git -C "${workdir}/repo" add CHANGELOG.md + fi + + if [ -n "$(git -C "${workdir}/repo" diff --name-only --diff-filter=U)" ]; then + git -C "${workdir}/repo" merge --abort >/dev/null 2>&1 || true + append_summary " - failed: predictable files were handled, but unmerged paths remain" + failed_count=$((failed_count + 1)) + + return + fi + + git -C "${workdir}/repo" commit -m "Resolve predictable conflicts with ${base_ref}" >/dev/null 2>&1 + git -C "${workdir}/repo" push origin "HEAD:${head_ref}" >/dev/null 2>&1 + append_summary " - resolved: pushed an automatic conflict-resolution commit for \`${title}\`" + + if ! dispatch_required_tests "${head_ref}"; then + failed_count=$((failed_count + 1)) + + return + fi + + resolved_count=$((resolved_count + 1)) +} + +if [ -z "${GH_TOKEN:-}" ]; then + echo "GH_TOKEN is required." >&2 + + exit 1 +fi + +append_summary "## Predictable Conflict Resolution Summary" +append_summary "" +append_summary "- Base branch: \`${base_ref}\`" + +pull_requests="$(collect_pull_requests)" + +if [ "${pull_requests:0:1}" = "{" ]; then + pull_requests="[${pull_requests}]" +fi + +while IFS= read -r pull_request; do + [ -n "${pull_request}" ] || continue + + resolve_pull_request \ + "$(jq -r '.number' <<< "${pull_request}")" \ + "$(jq -r '.title' <<< "${pull_request}")" \ + "$(jq -r '.url' <<< "${pull_request}")" \ + "$(jq -r '.headRefName' <<< "${pull_request}")" \ + "$(jq -r '.headRepositoryOwner.login' <<< "${pull_request}")" \ + "$(jq -r '.isCrossRepository' <<< "${pull_request}")" \ + "$(jq -r '.baseRefName' <<< "${pull_request}")" \ + "$(jq -r '.mergeable // "UNKNOWN"' <<< "${pull_request}")" +done < <(jq -c '.[]' <<< "${pull_requests}") + +append_summary "" +append_summary "- Resolved: ${resolved_count}" +append_summary "- Skipped: ${skipped_count}" +append_summary "- Failed: ${failed_count}" + +if [ "${failed_count}" -gt 0 ]; then + exit 1 +fi diff --git a/.github/actions/label-sync/copy-linked-issue-labels/action.yml b/.github/actions/label-sync/copy-linked-issue-labels/action.yml new file mode 100644 index 0000000..5608e20 --- /dev/null +++ b/.github/actions/label-sync/copy-linked-issue-labels/action.yml @@ -0,0 +1,11 @@ +name: Copy Linked Issue Labels +description: Copy labels from a pull request's linked issue onto the pull request. + +runs: + using: composite + steps: + - name: Copy linked issue labels + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: ${{ github.action_path }}/run.sh diff --git a/.github/actions/label-sync/copy-linked-issue-labels/run.sh b/.github/actions/label-sync/copy-linked-issue-labels/run.sh new file mode 100755 index 0000000..adb0a4f --- /dev/null +++ b/.github/actions/label-sync/copy-linked-issue-labels/run.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +title="$(jq -r '.pull_request.title // ""' "${GITHUB_EVENT_PATH}")" +body="$(jq -r '.pull_request.body // ""' "${GITHUB_EVENT_PATH}")" +pull_request_number="$(jq -r '.pull_request.number // ""' "${GITHUB_EVENT_PATH}")" +issue_number="$(jq -rn \ + --arg title "${title}" \ + --arg body "${body}" ' + [$title, $body] + | join(" ") + | try (capture("(?i)(closes|fixes|resolves|addresses)\\s+#(?[0-9]+)") | .issue) catch "" + ')" + +if [ -z "${issue_number}" ]; then + echo "No linked issue was found in the pull request title or body." + + exit 0 +fi + +labels="$(gh issue view "${issue_number}" --repo "${GITHUB_REPOSITORY}" --json labels --jq '.labels[].name' 2>/dev/null || true)" + +if [ -z "${labels}" ]; then + echo "Issue #${issue_number} has no labels to copy." + + exit 0 +fi + +while IFS= read -r label; do + if [ -n "${label}" ]; then + gh pr edit "${pull_request_number}" --repo "${GITHUB_REPOSITORY}" --add-label "${label}" 2>/dev/null || true + fi +done </dev/null 2>&1; then + fast-forward-actions php:detect-project --github-output + exit 0 + fi + + composer_json=false + docs_source=false + php_files=false + phpunit_config=false + test_files=false + + if [ -f composer.json ]; then + composer_json=true + fi + + if [ -f phpunit.xml ] || [ -f phpunit.xml.dist ]; then + phpunit_config=true + fi + + if [ -d docs ] && find docs -type f ! -name '.DS_Store' -print -quit | grep -q .; then + docs_source=true + fi + + if [ -d tests ] && find tests -type f -name '*.php' -print -quit | grep -q .; then + test_files=true + fi + + for directory in app bin config public src tests; do + if [ -d "${directory}" ] && find "${directory}" -type f -name '*.php' -print -quit | grep -q .; then + php_files=true + break + fi + done + + testable=false + if [ "${composer_json}" = "true" ] && [ "${phpunit_config}" = "true" ] && [ "${test_files}" = "true" ]; then + testable=true + fi + + reportable=false + if [ "${testable}" = "true" ] && [ "${docs_source}" = "true" ] && [ "${php_files}" = "true" ]; then + reportable=true + fi + + { + echo "composer-json=${composer_json}" + echo "docs-source=${docs_source}" + echo "php-files=${php_files}" + echo "phpunit-config=${phpunit_config}" + echo "test-files=${test_files}" + echo "testable=${testable}" + echo "reportable=${reportable}" + } >> "${GITHUB_OUTPUT}" + + echo "::notice title=PHP project surface::composer-json=${composer_json}, docs-source=${docs_source}, php-files=${php_files}, phpunit-config=${phpunit_config}, test-files=${test_files}, testable=${testable}, reportable=${reportable}" diff --git a/.github/actions/php/resolve-version/action.yml b/.github/actions/php/resolve-version/action.yml new file mode 100644 index 0000000..8e58d59 --- /dev/null +++ b/.github/actions/php/resolve-version/action.yml @@ -0,0 +1,159 @@ +name: Resolve PHP Version +description: Resolve the PHP version and test matrix from composer.lock or composer.json. + +outputs: + php-version: + description: Resolved PHP version used by setup-php. + value: ${{ steps.resolve.outputs.php-version }} + php-version-source: + description: Source used to resolve the PHP version. + value: ${{ steps.resolve.outputs.php-version-source }} + test-matrix: + description: JSON matrix of supported PHP minors starting from the inferred minimum. + value: ${{ steps.resolve.outputs.test-matrix }} + warning: + description: Warning emitted when the workflow falls back to the default PHP version. + value: ${{ steps.resolve.outputs.warning }} + +runs: + using: composite + steps: + - name: Resolve workflow PHP version + id: resolve + shell: bash + run: | + if [ -n "${FAST_FORWARD_ACTIONS_BIN:-}" ]; then + "${FAST_FORWARD_ACTIONS_BIN}" php:resolve-version --github-output + exit 0 + fi + + if command -v fast-forward-actions >/dev/null 2>&1; then + fast-forward-actions php:resolve-version --github-output + exit 0 + fi + + python3 <<'PY' + from __future__ import annotations + + import json + import os + import re + from pathlib import Path + + SUPPORTED_MINORS = ["8.3", "8.4", "8.5"] + DEFAULT_PHP_VERSION = "8.3" + + def version_to_tuple(version: str) -> tuple[int, int]: + major, minor = version.split(".") + return int(major), int(minor) + + def normalize_minor(version: str) -> str | None: + match = re.match(r"^\s*v?(8)\.(\d+)(?:\.\d+)?(?:\.\*)?\s*$", version) + if match is None: + return None + return f"{match.group(1)}.{match.group(2)}" + + def next_supported_minor(version: str) -> str | None: + if version not in SUPPORTED_MINORS: + return None + index = SUPPORTED_MINORS.index(version) + 1 + if index >= len(SUPPORTED_MINORS): + major, minor = version_to_tuple(version) + return f"{major}.{minor + 1}" + return SUPPORTED_MINORS[index] + + def infer_clause_lower_bound(clause: str) -> str | None: + tokens = re.findall(r"(\^|~|>=|>|<=|<|==|=)?\s*v?(8\.\d+(?:\.\d+)?(?:\.\*)?)", clause) + lower_bounds: list[str] = [] + for operator, version in tokens: + normalized = normalize_minor(version) + if normalized is None: + continue + if operator in ("", "=", "==", "^", "~", ">="): + lower_bounds.append(normalized) + continue + if operator == ">": + next_minor = next_supported_minor(normalized) + if next_minor is not None: + lower_bounds.append(next_minor) + if not lower_bounds: + return None + return max(lower_bounds, key=version_to_tuple) + + def infer_minimum_supported_minor(requirement: str) -> str | None: + clauses = [clause.strip() for clause in requirement.split("||")] + lower_bounds = [ + clause_lower_bound + for clause in clauses + if (clause_lower_bound := infer_clause_lower_bound(clause)) is not None + ] + if not lower_bounds: + return None + return min(lower_bounds, key=version_to_tuple) + + def resolve_from_lock(composer_lock: Path) -> tuple[str | None, str | None]: + if not composer_lock.exists(): + return None, None + try: + payload = json.loads(composer_lock.read_text()) + except json.JSONDecodeError: + return None, "composer.lock exists but could not be parsed" + platform_overrides = payload.get("platform-overrides") or {} + platform_php = platform_overrides.get("php") + if isinstance(platform_php, str): + resolved = normalize_minor(platform_php) + if resolved is not None: + return resolved, "composer.lock platform-overrides.php" + return None, "composer.lock platform-overrides.php is not a supported PHP version" + return None, None + + def resolve_from_json(composer_json: Path) -> tuple[str | None, str | None]: + if not composer_json.exists(): + return None, "composer.json does not exist" + try: + payload = json.loads(composer_json.read_text()) + except json.JSONDecodeError: + return None, "composer.json could not be parsed" + config_platform_php = (((payload.get("config") or {}).get("platform") or {}).get("php")) + if isinstance(config_platform_php, str): + resolved = normalize_minor(config_platform_php) + if resolved is not None: + return resolved, "composer.json config.platform.php" + return None, "composer.json config.platform.php is not a supported PHP version" + require_php = ((payload.get("require") or {}).get("php")) + if isinstance(require_php, str): + resolved = infer_minimum_supported_minor(require_php) + if resolved is not None: + return resolved, "composer.json require.php" + return None, "composer.json require.php could not be resolved safely" + return None, None + + def resolve_php_version() -> tuple[str, str, str | None]: + resolved, source = resolve_from_lock(Path("composer.lock")) + if resolved is None: + resolved, source = resolve_from_json(Path("composer.json")) + if resolved is None: + return DEFAULT_PHP_VERSION, "fallback", "No reliable PHP version source was found. Falling back to 8.3." + if resolved not in SUPPORTED_MINORS: + return DEFAULT_PHP_VERSION, "fallback", ( + f"Resolved PHP version {resolved} from {source} is outside the supported CI policy. Falling back to 8.3." + ) + return resolved, source or "fallback", None + + resolved_version, source, warning = resolve_php_version() + matrix_versions = [version for version in SUPPORTED_MINORS if version_to_tuple(version) >= version_to_tuple(resolved_version)] + matrix = json.dumps({"php-version": matrix_versions}, separators=(",", ":")) + + print(f"Resolved PHP version source: {source}") + print(f"Resolved PHP version: {resolved_version}") + print(f"Resolved PHP test matrix: {matrix_versions}") + if warning: + print(f"Warning: {warning}") + + github_output = os.environ["GITHUB_OUTPUT"] + with open(github_output, "a", encoding="utf-8") as handle: + handle.write(f"php-version={resolved_version}\n") + handle.write(f"php-version-source={source}\n") + handle.write(f"test-matrix={matrix}\n") + handle.write(f"warning={warning or ''}\n") + PY diff --git a/.github/actions/php/setup-composer/action.yml b/.github/actions/php/setup-composer/action.yml new file mode 100644 index 0000000..7892ed0 --- /dev/null +++ b/.github/actions/php/setup-composer/action.yml @@ -0,0 +1,80 @@ +name: Setup PHP and Composer Dependencies +description: Setup PHP, warm Composer cache, mark safe directories, and install dependencies. + +inputs: + php-version: + description: PHP version passed to setup-php. + required: true + extensions: + description: Comma-separated PHP extensions to enable. + required: false + default: '' + coverage: + description: Coverage driver passed to setup-php. + required: false + default: none + cache-dir: + description: Composer cache directory. + required: false + default: /tmp/composer-cache + root-version: + description: Value exported as COMPOSER_ROOT_VERSION during install. + required: false + default: '' + install-options: + description: Extra options passed to composer install. + required: false + default: --prefer-dist --no-progress --no-interaction --no-scripts + safe-directories: + description: Additional newline-separated directories to mark as safe for git. + required: false + default: '' + +runs: + using: composite + steps: + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + extensions: ${{ inputs.extensions }} + coverage: ${{ inputs.coverage }} + + - name: Cache Composer dependencies + uses: actions/cache@v5 + with: + path: ${{ inputs.cache-dir }} + key: ${{ runner.os }}-composer-${{ inputs.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer-${{ inputs.php-version }}- + ${{ runner.os }}-composer- + + - name: Mark workspace as safe for git + shell: bash + env: + INPUT_SAFE_DIRECTORIES: ${{ inputs.safe-directories }} + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + + if [ -n "${INPUT_SAFE_DIRECTORIES}" ]; then + printf '%s\n' "${INPUT_SAFE_DIRECTORIES}" | while IFS= read -r directory; do + if [ -n "${directory}" ]; then + git config --global --add safe.directory "${directory}" + fi + done + fi + + - name: Install dependencies + shell: bash + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"} }' + COMPOSER_CACHE_DIR: ${{ inputs.cache-dir }} + COMPOSER_ROOT_VERSION: ${{ inputs.root-version }} + INPUT_INSTALL_OPTIONS: ${{ inputs.install-options }} + run: | + if [ ! -f composer.json ]; then + echo "::notice title=Composer install skipped::composer.json was not found in ${GITHUB_WORKSPACE}." + exit 0 + fi + + composer install ${INPUT_INSTALL_OPTIONS} diff --git a/.github/actions/project-board/resolve-pr-status/action.yml b/.github/actions/project-board/resolve-pr-status/action.yml new file mode 100644 index 0000000..6c424b4 --- /dev/null +++ b/.github/actions/project-board/resolve-pr-status/action.yml @@ -0,0 +1,24 @@ +name: Resolve Pull Request Project Status +description: Resolve the target project status for a pull request and its linked issue. + +outputs: + pull-request-status: + description: Target project status for the pull request. + value: ${{ steps.resolve.outputs.pull-request-status }} + linked-issue-node-id: + description: GraphQL node id for the linked issue, when present. + value: ${{ steps.resolve.outputs.linked-issue-node-id }} + linked-issue-status: + description: Target project status for the linked issue, when present. + value: ${{ steps.resolve.outputs.linked-issue-status }} + +runs: + using: composite + steps: + - id: resolve + uses: actions/github-script@v8 + with: + github-token: ${{ github.token }} + script: | + const run = require(`${process.env.GITHUB_ACTION_PATH}/run.cjs`); + await run({github, context, core}); diff --git a/.github/actions/project-board/resolve-pr-status/run.cjs b/.github/actions/project-board/resolve-pr-status/run.cjs new file mode 100644 index 0000000..6552640 --- /dev/null +++ b/.github/actions/project-board/resolve-pr-status/run.cjs @@ -0,0 +1,52 @@ +const board = require('../shared/project-board-client.cjs'); + +/** + * @param {{github: import('@actions/github/lib/utils').GitHub, context: any, core: any}} deps + * + * @returns {Promise} + */ +module.exports = async function resolvePrStatus({ github, context, core }) { + const pullRequest = context.payload.pull_request; + const text = `${pullRequest.title}\n${pullRequest.body ?? ''}`; + const issueNumber = board.parseLinkedIssueNumber(text); + + let pullRequestStatus = 'In review'; + let linkedIssueStatus = ''; + let linkedIssueNodeId = ''; + + if ('closed' === context.payload.action && pullRequest.merged !== true) { + pullRequestStatus = 'Backlog'; + linkedIssueStatus = 'Backlog'; + } else if (pullRequest.merged === true) { + pullRequestStatus = 'Merged'; + linkedIssueStatus = 'Merged'; + } else if (pullRequest.draft) { + pullRequestStatus = 'In progress'; + linkedIssueStatus = issueNumber ? 'In progress' : ''; + } else { + linkedIssueStatus = issueNumber ? 'In progress' : ''; + } + + if (issueNumber) { + const issue = await github.graphql( + `query($owner: String!, $repo: String!, $issueNumber: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $issueNumber) { + id + } + } + }`, + { + owner: context.repo.owner, + repo: context.repo.repo, + issueNumber, + }, + ); + + linkedIssueNodeId = issue.repository.issue?.id ?? ''; + } + + core.setOutput('pull-request-status', pullRequestStatus); + core.setOutput('linked-issue-node-id', linkedIssueNodeId); + core.setOutput('linked-issue-status', linkedIssueStatus); +}; diff --git a/.github/actions/project-board/resolve-review-status/action.yml b/.github/actions/project-board/resolve-review-status/action.yml new file mode 100644 index 0000000..9de19f1 --- /dev/null +++ b/.github/actions/project-board/resolve-review-status/action.yml @@ -0,0 +1,18 @@ +name: Resolve Review Project Status +description: Resolve the target project status for a pull request after a review event. + +outputs: + status: + description: Target project status for the reviewed pull request. + value: ${{ steps.resolve.outputs.status }} + +runs: + using: composite + steps: + - id: resolve + uses: actions/github-script@v8 + with: + github-token: ${{ github.token }} + script: | + const run = require(`${process.env.GITHUB_ACTION_PATH}/run.cjs`); + await run({github, context, core}); diff --git a/.github/actions/project-board/resolve-review-status/run.cjs b/.github/actions/project-board/resolve-review-status/run.cjs new file mode 100644 index 0000000..9561b92 --- /dev/null +++ b/.github/actions/project-board/resolve-review-status/run.cjs @@ -0,0 +1,38 @@ +/** + * @param {{github: import('@actions/github/lib/utils').GitHub, context: any, core: any}} deps + * + * @returns {Promise} + */ +module.exports = async function resolveReviewStatus({ github, context, core }) { + const result = await github.graphql( + `query($owner: String!, $repo: String!, $pullRequestNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pullRequestNumber) { + isDraft + reviewDecision + } + } + }`, + { + owner: context.repo.owner, + repo: context.repo.repo, + pullRequestNumber: context.payload.pull_request.number, + }, + ); + + const pullRequest = result.repository.pullRequest; + + if (pullRequest.isDraft) { + core.setOutput('status', 'In progress'); + + return; + } + + if ('APPROVED' === pullRequest.reviewDecision) { + core.setOutput('status', 'Ready to Merge'); + + return; + } + + core.setOutput('status', 'In review'); +}; diff --git a/.github/actions/project-board/resolve/action.yml b/.github/actions/project-board/resolve/action.yml new file mode 100644 index 0000000..3bc46e6 --- /dev/null +++ b/.github/actions/project-board/resolve/action.yml @@ -0,0 +1,26 @@ +name: Resolve Project Board +description: Resolve the GitHub Project V2 number for Fast Forward workflows. + +inputs: + project: + description: Optional GitHub Project V2 number provided explicitly by the caller. + required: false + default: '' + +outputs: + project-number: + description: Resolved GitHub Project V2 number or an empty string when no safe default exists. + value: ${{ steps.resolve.outputs.project-number }} + +runs: + using: composite + steps: + - id: resolve + uses: actions/github-script@v8 + env: + INPUT_PROJECT: ${{ inputs.project }} + with: + github-token: ${{ github.token }} + script: | + const run = require(`${process.env.GITHUB_ACTION_PATH}/run.cjs`); + await run({github, context, core}); diff --git a/.github/actions/project-board/resolve/run.cjs b/.github/actions/project-board/resolve/run.cjs new file mode 100644 index 0000000..6c5751a --- /dev/null +++ b/.github/actions/project-board/resolve/run.cjs @@ -0,0 +1,49 @@ +/** + * @param {{github: import('@actions/github/lib/utils').GitHub, context: any, core: any}} deps + * + * @returns {Promise} + */ +module.exports = async function resolveProject({ github, context, core }) { + const configuredProjectNumber = (process.env.INPUT_PROJECT ?? '').trim(); + + if (configuredProjectNumber) { + core.setOutput('project-number', configuredProjectNumber); + + return; + } + + if ('php-fast-forward' !== context.repo.owner) { + core.info('No project number was provided. Consumer repositories SHOULD pass project or configure PROJECT in their wrapper workflow.'); + core.setOutput('project-number', ''); + + return; + } + + const result = await github.graphql( + `query($owner: String!) { + organization(login: $owner) { + projectsV2(first: 1, orderBy: {field: TITLE, direction: ASC}) { + nodes { + number + title + } + } + } + }`, + { + owner: context.repo.owner, + }, + ); + + const project = result.organization?.projectsV2?.nodes?.[0] ?? null; + + if (!project) { + core.info(`No GitHub Project V2 was found for ${context.repo.owner}.`); + core.setOutput('project-number', ''); + + return; + } + + core.info(`Defaulting to organization project #${project.number} (${project.title}).`); + core.setOutput('project-number', String(project.number)); +}; diff --git a/.github/actions/project-board/shared/project-board-client.cjs b/.github/actions/project-board/shared/project-board-client.cjs new file mode 100644 index 0000000..09804f2 --- /dev/null +++ b/.github/actions/project-board/shared/project-board-client.cjs @@ -0,0 +1,250 @@ +/** + * Fast Forward Development Tools for PHP projects. + * + * This file is part of fast-forward/dev-tools project. + * + * (c) Mentor do Nerd + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +/** + * Parses the first linked issue number from a pull request title/body pair. + * + * @param {string} text + * + * @returns {number|null} + */ +function parseLinkedIssueNumber(text) { + const match = text.match(/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|address(?:e[sd])?)\s+#(\d+)/i); + + if (!match) { + return null; + } + + return Number(match[1]); +} + +/** + * Loads the configured GitHub Project for the repository workflow. + * + * @param {import('@actions/github/lib/utils').GitHub} github + * @param {string} projectOwner + * @param {number|string} projectNumber + * + * @returns {Promise} + */ +async function loadConfiguredProject(github, projectOwner, projectNumber) { + if (!projectOwner) { + return null; + } + + if (!projectNumber) { + if ('php-fast-forward' !== projectOwner) { + return null; + } + + const fallback = await github.graphql( + `query($owner: String!) { + organization(login: $owner) { + projectsV2(first: 1, orderBy: {field: TITLE, direction: ASC}) { + nodes { + id + number + title + fields(first: 50) { + nodes { + __typename + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + color + description + } + } + } + } + } + } + } + }`, + { + owner: projectOwner, + }, + ); + + return fallback.organization?.projectsV2?.nodes?.[0] ?? null; + } + + const result = await github.graphql( + `query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + title + fields(first: 50) { + nodes { + __typename + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + color + description + } + } + } + } + } + } + user(login: $owner) { + projectV2(number: $number) { + id + title + fields(first: 50) { + nodes { + __typename + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + color + description + } + } + } + } + } + } + }`, + { + owner: projectOwner, + number: Number(projectNumber), + }, + ); + + return result.organization?.projectV2 ?? result.user?.projectV2 ?? null; +} + +/** + * Returns the single-select field by name when present. + * + * @param {object} project + * @param {string} fieldName + * + * @returns {object|null} + */ +function getSingleSelectField(project, fieldName) { + return project.fields.nodes.find( + (field) => 'ProjectV2SingleSelectField' === field.__typename && field.name === fieldName, + ) ?? null; +} + +/** + * Resolves the option metadata for the named single-select value. + * + * @param {object} project + * @param {string} fieldName + * @param {string} optionName + * + * @returns {object|null} + */ +function getSingleSelectOption(project, fieldName, optionName) { + const field = getSingleSelectField(project, fieldName); + + if (!field) { + return null; + } + + return field.options.find((option) => option.name === optionName) ?? null; +} + +/** + * Returns the existing project item field value by field name. + * + * @param {object} item + * @param {string} fieldName + * + * @returns {string|null} + */ +function getExistingFieldValue(item, fieldName) { + if (!item?.fieldValues?.nodes) { + return null; + } + + for (const node of item.fieldValues.nodes) { + if ('ProjectV2ItemFieldSingleSelectValue' === node.__typename && node.field?.name === fieldName) { + return node.name; + } + } + + return null; +} + +/** + * Updates a single-select field value by option id. + * + * @param {import('@actions/github/lib/utils').GitHub} github + * @param {string} projectId + * @param {string} itemId + * @param {string} fieldId + * @param {string} optionId + * + * @returns {Promise} + */ +async function updateSingleSelectField(github, projectId, itemId, fieldId, optionId) { + await github.graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue( + input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: {singleSelectOptionId: $optionId} + } + ) { + projectV2Item { + id + } + } + }`, + { + projectId, + itemId, + fieldId, + optionId, + }, + ); +} + +/** + * Finds the matching project item for a known project id. + * + * @param {Array} items + * @param {string} projectId + * + * @returns {object|null} + */ +function findProjectItem(items, projectId) { + return items.find((item) => item.project?.id === projectId) ?? null; +} + +module.exports = { + findProjectItem, + getExistingFieldValue, + getSingleSelectField, + getSingleSelectOption, + loadConfiguredProject, + parseLinkedIssueNumber, + updateSingleSelectField, +}; diff --git a/.github/actions/project-board/sync-linked-pr-metadata/action.yml b/.github/actions/project-board/sync-linked-pr-metadata/action.yml new file mode 100644 index 0000000..294bc6d --- /dev/null +++ b/.github/actions/project-board/sync-linked-pr-metadata/action.yml @@ -0,0 +1,12 @@ +name: Sync Linked Pull Request Metadata +description: Copy milestone and inferable project metadata from the linked issue to the pull request. + +runs: + using: composite + steps: + - uses: actions/github-script@v8 + with: + github-token: ${{ github.token }} + script: | + const run = require(`${process.env.GITHUB_ACTION_PATH}/run.cjs`); + await run({github, context, core}); diff --git a/.github/actions/project-board/sync-linked-pr-metadata/run.cjs b/.github/actions/project-board/sync-linked-pr-metadata/run.cjs new file mode 100644 index 0000000..bdd5087 --- /dev/null +++ b/.github/actions/project-board/sync-linked-pr-metadata/run.cjs @@ -0,0 +1,156 @@ +const board = require('../shared/project-board-client.cjs'); + +/** + * @param {{github: import('@actions/github/lib/utils').GitHub, context: any, core: any}} deps + * + * @returns {Promise} + */ +module.exports = async function syncLinkedPrMetadata({ github, context, core }) { + const pullRequest = context.payload.pull_request; + const text = `${pullRequest.title}\n${pullRequest.body ?? ''}`; + const issueNumber = board.parseLinkedIssueNumber(text); + + if (!issueNumber) { + core.info('No linked issue reference found in the pull request title or body.'); + + return; + } + + const owner = context.repo.owner; + const repo = context.repo.repo; + const pullRequestNumber = pullRequest.number; + + const metadata = await github.graphql( + `query($owner: String!, $repo: String!, $issueNumber: Int!, $pullRequestNumber: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $issueNumber) { + id + number + milestone { + number + title + } + projectItems(first: 20) { + nodes { + id + project { + ... on ProjectV2 { + id + title + } + } + fieldValues(first: 20) { + nodes { + __typename + ... on ProjectV2ItemFieldSingleSelectValue { + optionId + name + field { + ... on ProjectV2SingleSelectField { + id + name + } + } + } + } + } + } + } + } + pullRequest(number: $pullRequestNumber) { + projectItems(first: 20) { + nodes { + id + project { + ... on ProjectV2 { + id + title + } + } + fieldValues(first: 20) { + nodes { + __typename + ... on ProjectV2ItemFieldSingleSelectValue { + field { + ... on ProjectV2SingleSelectField { + name + } + } + name + } + } + } + } + } + } + } + }`, + { + owner, + repo, + issueNumber, + pullRequestNumber, + }, + ); + + const issue = metadata.repository.issue; + + if (!issue) { + core.info(`Linked issue #${issueNumber} was not found.`); + + return; + } + + if (issue.milestone) { + core.info(`Applying milestone "${issue.milestone.title}" to PR #${pullRequestNumber}.`); + await github.request('PATCH /repos/{owner}/{repo}/issues/{issue_number}', { + owner, + repo, + issue_number: pullRequestNumber, + milestone: issue.milestone.number, + }); + } + + const issueProjectItems = issue.projectItems.nodes; + const pullRequestProjectItems = metadata.repository.pullRequest.projectItems.nodes; + + for (const issueProjectItem of issueProjectItems) { + const project = issueProjectItem.project; + + if (!project) { + continue; + } + + const pullRequestProjectItem = pullRequestProjectItems.find((item) => item.project?.id === project.id) ?? null; + + if (!pullRequestProjectItem) { + continue; + } + + const pullRequestFieldValues = new Map( + pullRequestProjectItem.fieldValues.nodes + .filter((fieldValue) => 'ProjectV2ItemFieldSingleSelectValue' === fieldValue.__typename) + .map((fieldValue) => [fieldValue.field?.name, fieldValue]), + ); + + for (const fieldName of ['Priority', 'Size']) { + const issueValue = issueProjectItem.fieldValues.nodes.find( + (fieldValue) => 'ProjectV2ItemFieldSingleSelectValue' === fieldValue.__typename && fieldValue.field?.name === fieldName, + ) ?? null; + const currentValue = pullRequestFieldValues.get(fieldName) ?? null; + + if (!issueValue || currentValue) { + continue; + } + + await board.updateSingleSelectField( + github, + project.id, + pullRequestProjectItem.id, + issueValue.field.id, + issueValue.optionId, + ); + core.info(`PR #${pullRequestNumber} inherited ${fieldName}="${issueValue.name}" from issue #${issue.number}.`); + } + } +}; diff --git a/.github/actions/project-board/sync-status/action.yml b/.github/actions/project-board/sync-status/action.yml new file mode 100644 index 0000000..9a376de --- /dev/null +++ b/.github/actions/project-board/sync-status/action.yml @@ -0,0 +1,27 @@ +name: Sync Project Board Status +description: Sync a GitHub Issue or Pull Request status into the configured Project V2. + +inputs: + project: + description: Target GitHub Project V2 number. + required: true + organization: + description: Organization or user owner of the target project. + required: true + resource-node-id: + description: GraphQL node id of the issue or pull request to update. + required: true + status-value: + description: Target status option name. + required: true + +runs: + using: composite + steps: + - uses: leonsteinhaeuser/project-beta-automations@v2.2.1 + with: + gh_token: ${{ github.token }} + organization: ${{ inputs.organization }} + project_id: ${{ inputs.project }} + resource_node_id: ${{ inputs.resource-node-id }} + status_value: ${{ inputs.status-value }} diff --git a/.github/actions/project-board/transition-status/action.yml b/.github/actions/project-board/transition-status/action.yml new file mode 100644 index 0000000..8112de3 --- /dev/null +++ b/.github/actions/project-board/transition-status/action.yml @@ -0,0 +1,51 @@ +name: Transition Project Board Status +description: Move all matching issue and pull-request items from one project status to another. + +inputs: + project: + description: Optional GitHub Project V2 number. Fast Forward repositories MAY omit it to use the first organization project. + required: false + default: '' + from-status: + description: Source project status name. + required: false + default: '' + from-statuses: + description: Comma-separated source project status names. + required: false + default: '' + to-status: + description: Destination project status name. + required: true + include-current-pull-request: + description: Whether to include the current pull request item in the transition pass. + required: false + default: 'false' + +outputs: + moved-count: + description: Number of project items moved to the destination status. + value: ${{ steps.transition.outputs.moved-count }} + skipped-count: + description: Number of project items inspected but not moved. + value: ${{ steps.transition.outputs.skipped-count }} + source-statuses: + description: Comma-separated source statuses used for the transition. + value: ${{ steps.transition.outputs.source-statuses }} + +runs: + using: composite + steps: + - id: transition + uses: actions/github-script@v8 + env: + INPUT_PROJECT: ${{ inputs.project }} + INPUT_FROM_STATUS: ${{ inputs.from-status }} + INPUT_FROM_STATUSES: ${{ inputs.from-statuses }} + INPUT_TO_STATUS: ${{ inputs.to-status }} + INPUT_INCLUDE_CURRENT_PULL_REQUEST: ${{ inputs.include-current-pull-request }} + with: + github-token: ${{ github.token }} + script: | + const run = require(`${process.env.GITHUB_ACTION_PATH}/run.cjs`); + await run({github, context, core}); diff --git a/.github/actions/project-board/transition-status/run.cjs b/.github/actions/project-board/transition-status/run.cjs new file mode 100644 index 0000000..22b6777 --- /dev/null +++ b/.github/actions/project-board/transition-status/run.cjs @@ -0,0 +1,187 @@ +const board = require('../shared/project-board-client.cjs'); + +/** + * @param {{github: import('@actions/github/lib/utils').GitHub, context: any, core: any}} deps + * + * @returns {Promise} + */ +module.exports = async function transitionStatus({ github, context, core }) { + const includeCurrentPullRequest = 'true' === (process.env.INPUT_INCLUDE_CURRENT_PULL_REQUEST ?? '').toLowerCase(); + const sourceStatuses = [ + ...(process.env.INPUT_FROM_STATUSES ?? '').split(','), + process.env.INPUT_FROM_STATUS ?? '', + ] + .map((status) => status.trim()) + .filter((status, index, statuses) => '' !== status && statuses.indexOf(status) === index); + const toStatus = process.env.INPUT_TO_STATUS; + + core.setOutput('source-statuses', sourceStatuses.join(',')); + + if (0 === sourceStatuses.length) { + core.info('No source project statuses were provided. Skipping status transition.'); + core.setOutput('moved-count', '0'); + core.setOutput('skipped-count', '0'); + + return; + } + + const project = await board.loadConfiguredProject( + github, + context.repo.owner, + process.env.INPUT_PROJECT, + ); + + if (!project) { + core.info('No configured GitHub Project V2 was resolved. Skipping status transition.'); + core.setOutput('moved-count', '0'); + core.setOutput('skipped-count', '0'); + + return; + } + + const statusField = board.getSingleSelectField(project, 'Status'); + const targetOption = board.getSingleSelectOption(project, 'Status', toStatus); + + if (!statusField || !targetOption) { + core.info(`Project "${project.title}" does not expose the expected target status "${toStatus}".`); + core.setOutput('moved-count', '0'); + core.setOutput('skipped-count', '0'); + + return; + } + + const loadProjectItems = async () => { + const items = []; + let cursor = null; + + do { + const result = await github.graphql( + `query($project: ID!, $cursor: String) { + node(id: $project) { + ... on ProjectV2 { + items(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + content { + __typename + ... on Issue { + number + repository { + nameWithOwner + } + title + url + } + ... on PullRequest { + number + repository { + nameWithOwner + } + title + url + } + } + fieldValues(first: 20) { + nodes { + __typename + ... on ProjectV2ItemFieldSingleSelectValue { + field { + ... on ProjectV2SingleSelectField { + name + } + } + name + } + } + } + } + } + } + } + }`, + { + project: project.id, + cursor, + }, + ); + + const page = result.node?.items; + + items.push(...(page?.nodes ?? [])); + cursor = page?.pageInfo?.hasNextPage ? page.pageInfo.endCursor : null; + } while (null !== cursor); + + return items; + }; + + const formatLabel = (item) => { + const content = item.content; + + if ('Issue' === content?.__typename) { + return `Issue #${content.number}`; + } + + if ('PullRequest' === content?.__typename) { + return `PR #${content.number}`; + } + + return `Project item ${item.id}`; + }; + + const belongsToCurrentRepository = (item) => { + const repository = item.content?.repository?.nameWithOwner; + + return `${context.repo.owner}/${context.repo.repo}` === repository; + }; + + const moveToStatus = async (item, label) => { + const currentStatus = board.getExistingFieldValue(item, 'Status'); + + if (!sourceStatuses.includes(currentStatus)) { + return false; + } + + await board.updateSingleSelectField( + github, + project.id, + item.id, + statusField.id, + targetOption.id, + ); + + core.info(`${label} moved from ${currentStatus} to ${toStatus}.`); + + return true; + }; + + if (includeCurrentPullRequest) { + core.info('The include-current-pull-request input is kept for compatibility; project item pagination already includes the current pull request when it is on the board.'); + } + + let movedCount = 0; + let skippedCount = 0; + + for (const item of await loadProjectItems()) { + if (!belongsToCurrentRepository(item)) { + skippedCount++; + + continue; + } + + if (await moveToStatus(item, formatLabel(item))) { + movedCount++; + + continue; + } + + skippedCount++; + } + + core.info(`${movedCount} project item(s) moved to ${toStatus}; ${skippedCount} inspected item(s) skipped.`); + core.setOutput('moved-count', String(movedCount)); + core.setOutput('skipped-count', String(skippedCount)); +}; diff --git a/.github/actions/project-board/transition-status/run.test.cjs b/.github/actions/project-board/transition-status/run.test.cjs new file mode 100644 index 0000000..98c3d8f --- /dev/null +++ b/.github/actions/project-board/transition-status/run.test.cjs @@ -0,0 +1,239 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const transitionStatus = require('./run.cjs'); + +const project = { + id: 'project-1', + title: 'PHP Fast Forward Project', + fields: { + nodes: [ + { + __typename: 'ProjectV2SingleSelectField', + id: 'status-field', + name: 'Status', + options: [ + { + id: 'released-option', + name: 'Released', + }, + ], + }, + ], + }, +}; + +/** + * @param {string} id + * @param {string} status + * @param {'Issue'|'PullRequest'} type + * @param {number} number + * @param {string} repository + * + * @returns {object} + */ +function projectItem(id, status, type = 'Issue', number = 1, repository = 'php-fast-forward/dev-tools') { + return { + id, + content: { + __typename: type, + number, + repository: { + nameWithOwner: repository, + }, + }, + project: { + id: project.id, + }, + fieldValues: { + nodes: [ + { + __typename: 'ProjectV2ItemFieldSingleSelectValue', + field: { + name: 'Status', + }, + name: status, + }, + ], + }, + }; +} + +/** + * @param {Array} projectItems + * + * @returns {{github: {graphql: Function}, mutations: Array}} + */ +function createGithub(projectItems) { + const mutations = []; + const github = { + graphql: async (query, variables) => { + if (query.includes('updateProjectV2ItemFieldValue')) { + mutations.push(variables); + + return { + updateProjectV2ItemFieldValue: { + projectV2Item: { + id: variables.itemId, + }, + }, + }; + } + + if (query.includes('node(id: $project)')) { + return { + node: { + items: { + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + nodes: projectItems, + }, + }, + }; + } + + if (query.includes('projectV2(number: $number)')) { + return { + organization: { + projectV2: project, + }, + user: { + projectV2: null, + }, + }; + } + + throw new Error(`Unexpected GraphQL operation: ${query}`); + }, + }; + + return { + github, + mutations, + }; +} + +/** + * @returns {{info: Array, outputs: Record, core: object}} + */ +function createCore() { + const info = []; + const outputs = {}; + + return { + info, + outputs, + core: { + info: (message) => info.push(message), + setOutput: (name, value) => { + outputs[name] = value; + }, + }, + }; +} + +/** + * @param {Record} env + * @param {Function} callback + * + * @returns {Promise} + */ +async function withEnvironment(env, callback) { + const previous = {}; + + for (const key of Object.keys(env)) { + previous[key] = process.env[key]; + process.env[key] = env[key]; + } + + try { + await callback(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (undefined === value) { + delete process.env[key]; + + continue; + } + + process.env[key] = value; + } + } +} + +test('moves items from multiple source statuses to the release status', async () => { + const projectItems = [ + projectItem('current-pr', 'Release Prepared', 'PullRequest', 10), + projectItem('merged-pr', 'Merged', 'PullRequest', 9), + projectItem('foreign-merged-pr', 'Merged', 'PullRequest', 7, 'php-fast-forward/enum'), + projectItem('backlog-issue', 'Backlog', 'Issue', 8), + ]; + const {github, mutations} = createGithub(projectItems); + const {core, outputs} = createCore(); + + await withEnvironment({ + INPUT_INCLUDE_CURRENT_PULL_REQUEST: 'true', + INPUT_FROM_STATUS: '', + INPUT_FROM_STATUSES: 'Release Prepared,Merged', + INPUT_TO_STATUS: 'Released', + INPUT_PROJECT: '1', + }, async () => { + await transitionStatus({ + github, + context: { + repo: { + owner: 'php-fast-forward', + repo: 'dev-tools', + }, + payload: { + pull_request: { + number: 10, + }, + }, + }, + core, + }); + }); + + assert.deepEqual(mutations.map((mutation) => mutation.itemId), ['current-pr', 'merged-pr']); + assert.equal(outputs['source-statuses'], 'Release Prepared,Merged'); + assert.equal(outputs['moved-count'], '2'); + assert.equal(outputs['skipped-count'], '2'); +}); + +test('keeps the legacy from-status input supported', async () => { + const projectItems = [ + projectItem('merged-issue', 'Merged', 'Issue', 8), + ]; + const {github, mutations} = createGithub(projectItems); + const {core, outputs} = createCore(); + + await withEnvironment({ + INPUT_INCLUDE_CURRENT_PULL_REQUEST: 'false', + INPUT_FROM_STATUS: 'Merged', + INPUT_FROM_STATUSES: '', + INPUT_TO_STATUS: 'Released', + INPUT_PROJECT: '1', + }, async () => { + await transitionStatus({ + github, + context: { + repo: { + owner: 'php-fast-forward', + repo: 'dev-tools', + }, + payload: {}, + }, + core, + }); + }); + + assert.deepEqual(mutations.map((mutation) => mutation.itemId), ['merged-issue']); + assert.equal(outputs['source-statuses'], 'Merged'); + assert.equal(outputs['moved-count'], '1'); + assert.equal(outputs['skipped-count'], '0'); +}); diff --git a/.github/actions/review/render-request/action.yml b/.github/actions/review/render-request/action.yml new file mode 100644 index 0000000..25d8bef --- /dev/null +++ b/.github/actions/review/render-request/action.yml @@ -0,0 +1,30 @@ +name: Render Rigorous Review Request +description: Render a deterministic review brief for a pull request that is ready for rigorous review. + +inputs: + pull-request-number: + description: Pull request number to review. + required: false + default: '' + +outputs: + pull-request-number: + description: Resolved pull request number. + value: ${{ steps.render.outputs.pull-request-number }} + comment: + description: Sticky pull-request comment body. + value: ${{ steps.render.outputs.comment }} + summary: + description: GitHub Actions step summary body. + value: ${{ steps.render.outputs.summary }} + +runs: + using: composite + steps: + - id: render + uses: actions/github-script@v8 + with: + github-token: ${{ github.token }} + script: | + const run = require(`${process.env.GITHUB_ACTION_PATH}/run.cjs`); + await run({github, context, core}); diff --git a/.github/actions/review/render-request/run.cjs b/.github/actions/review/render-request/run.cjs new file mode 100644 index 0000000..9181685 --- /dev/null +++ b/.github/actions/review/render-request/run.cjs @@ -0,0 +1,151 @@ +const MAX_LISTED_FILES = 12; + +function classifyTouchedSurfaces(files) { + const checks = [ + [ + 'Source behavior or orchestration changed; verify regressions, contracts, and dependency boundaries.', + (file) => file.startsWith('src/'), + ], + [ + 'Tests changed; confirm assertions still cover the touched behavior and edge cases.', + (file) => file.startsWith('tests/'), + ], + [ + 'Docs or README changed; verify commands, examples, and generated outputs stayed aligned.', + (file) => file === 'README.md' || file.startsWith('docs/'), + ], + [ + 'Workflow or local GitHub Action logic changed; require executable validation of permissions, triggers, bot-authored commits, and CI side effects.', + (file) => file.startsWith('.github/workflows/') || file.startsWith('.github/actions/'), + ], + [ + 'Consumer workflow wrappers changed; verify permissions, inputs, and reusable workflow refs remain aligned with the packaged contract.', + (file) => file.startsWith('resources/github-actions/'), + ], + [ + 'Packaged agent surfaces changed; verify prompts, sync behavior, and inherited guidance.', + (file) => file.startsWith('.agents/skills/') || file.startsWith('.agents/agents/') || file === 'AGENTS.md', + ], + [ + 'Changelog changed; verify notable behavior and automation changes are documented accurately.', + (file) => file === 'CHANGELOG.md', + ], + [ + 'Wiki output changed; confirm generated content updates are intentional and consistent with the source.', + (file) => file.startsWith('.github/wiki'), + ], + [ + 'Packaged resources changed; verify consumer repositories inherit the right defaults and generated artifacts.', + (file) => file.startsWith('resources/'), + ], + ]; + + return checks + .filter(([, matcher]) => files.some(matcher)) + .map(([message]) => message); +} + +function renderComment({pull, files, focusAreas}) { + const listedFiles = files.slice(0, MAX_LISTED_FILES); + const remainingCount = Math.max(0, files.length - listedFiles.length); + const touchedSurfaces = focusAreas.length > 0 + ? focusAreas + : ['No special review surface was inferred beyond the normal findings-first review contract.']; + + const lines = [ + '## Rigorous review requested', + '', + 'This pull request is ready for the dedicated `review-guardian` pass powered by `$pull-request-review`.', + '', + `- PR: #${pull.number} — ${pull.title}`, + `- Author: @${pull.user.login}`, + `- Base: \`${pull.base.ref}\``, + `- Head: \`${pull.head.ref}\` @ \`${pull.head.sha.slice(0, 7)}\``, + '', + '### Review focus', + ...touchedSurfaces.map((item) => `- ${item}`), + '', + '### Sample changed files', + ...listedFiles.map((file) => `- \`${file}\``), + ]; + + if (remainingCount > 0) { + lines.push(`- ...and ${remainingCount} more files.`); + } + + lines.push( + '', + '### Suggested prompt', + '```text', + `Use $pull-request-review with the review-guardian agent to review PR #${pull.number} (${pull.html_url}).`, + 'Lead with findings ordered by severity. Include repository file references whenever possible.', + 'Prioritize bugs, regressions, missing tests, missing docs, generated-output drift, and workflow or CI impacts.', + 'For workflow/action changes, record validation evidence or residual risk; pay special attention to GITHUB_TOKEN pushes and required-check dispatch/mirroring.', + `Base: ${pull.base.ref}`, + `Head: ${pull.head.ref} @ ${pull.head.sha.slice(0, 7)}`, + '```', + ); + + return lines.join('\n'); +} + +function renderSummary({pull, focusAreas}) { + const lines = [ + '## Rigorous review requested', + '', + `- Pull request: [#${pull.number}](${pull.html_url})`, + '- Agent: `review-guardian`', + '- Skill: `$pull-request-review`', + '', + '### Findings-first expectations', + '- Lead with bugs, regressions, missing tests, missing docs, generated-output drift, and workflow or CI risk.', + '- Include repository file references whenever possible.', + '- For workflow/action changes, record executable validation evidence or residual risk, especially around bot-authored commits and required checks.', + ]; + + if (focusAreas.length > 0) { + lines.push('', '### Inferred high-signal surfaces', ...focusAreas.map((item) => `- ${item}`)); + } + + return lines.join('\n'); +} + +module.exports = async function run({github, context, core}) { + const owner = context.repo.owner; + const repo = context.repo.repo; + const input = core.getInput('pull-request-number').trim(); + const inferredNumber = String(context.payload.pull_request?.number || '').trim(); + const pullRequestNumber = input || inferredNumber; + + if (pullRequestNumber === '') { + core.setFailed('Unable to resolve a pull request number for the rigorous review workflow.'); + return; + } + + const pull_number = Number.parseInt(pullRequestNumber, 10); + + if (Number.isNaN(pull_number)) { + core.setFailed(`Invalid pull request number: ${pullRequestNumber}`); + return; + } + + const {data: pull} = await github.rest.pulls.get({ + owner, + repo, + pull_number, + }); + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number, + per_page: 100, + }); + + const changedFiles = files.map((file) => file.filename); + const focusAreas = classifyTouchedSurfaces(changedFiles); + + core.setOutput('pull-request-number', String(pull.number)); + core.setOutput('comment', renderComment({pull, files: changedFiles, focusAreas})); + core.setOutput('summary', renderSummary({pull, focusAreas})); +}; diff --git a/.github/actions/summary/write/action.yml b/.github/actions/summary/write/action.yml new file mode 100644 index 0000000..4627061 --- /dev/null +++ b/.github/actions/summary/write/action.yml @@ -0,0 +1,16 @@ +name: Write Workflow Step Summary +description: Append deterministic Markdown to GITHUB_STEP_SUMMARY when content is available. + +inputs: + markdown: + description: Markdown content to append to the workflow step summary. + required: true + +runs: + using: composite + steps: + - name: Append summary + shell: bash + env: + INPUT_MARKDOWN: ${{ inputs.markdown }} + run: ${{ github.action_path }}/run.sh diff --git a/.github/actions/summary/write/run.sh b/.github/actions/summary/write/run.sh new file mode 100755 index 0000000..c561ee5 --- /dev/null +++ b/.github/actions/summary/write/run.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -z "${INPUT_MARKDOWN}" ]; then + exit 0 +fi + +printf '%s\n' "${INPUT_MARKDOWN}" >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/actions/wiki/cleanup-orphaned-previews/action.yml b/.github/actions/wiki/cleanup-orphaned-previews/action.yml new file mode 100644 index 0000000..8d63881 --- /dev/null +++ b/.github/actions/wiki/cleanup-orphaned-previews/action.yml @@ -0,0 +1,24 @@ +name: Cleanup Orphaned Wiki Previews +description: Delete wiki preview branches for pull requests that are no longer open. + +runs: + using: composite + steps: + - id: cleanup + name: Cleanup orphaned previews + shell: bash + env: + GH_TOKEN: ${{ github.token }} + working-directory: .github/wiki + run: ${{ github.action_path }}/run.sh + +outputs: + deleted: + description: Number of deleted preview branches. + value: ${{ steps.cleanup.outputs.deleted }} + skipped: + description: Number of retained preview branches. + value: ${{ steps.cleanup.outputs.skipped }} + unresolved: + description: Number of unresolved preview branches. + value: ${{ steps.cleanup.outputs.unresolved }} diff --git a/.github/actions/wiki/cleanup-orphaned-previews/run.sh b/.github/actions/wiki/cleanup-orphaned-previews/run.sh new file mode 100755 index 0000000..d3ce25e --- /dev/null +++ b/.github/actions/wiki/cleanup-orphaned-previews/run.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +git fetch origin '+refs/heads/pr-*:refs/remotes/origin/pr-*' || true + +deleted=0 +skipped=0 +unresolved=0 + +while read -r remote_branch; do + branch="${remote_branch#origin/}" + pull_request_number="${branch#pr-}" + + if ! [[ "${pull_request_number}" =~ ^[0-9]+$ ]]; then + echo "Skipping non-PR wiki preview branch ${branch}." + skipped=$((skipped + 1)) + continue + fi + + state="$(gh pr view "${pull_request_number}" --repo "${GITHUB_REPOSITORY}" --json state --jq '.state' 2>/dev/null || echo UNKNOWN)" + + case "${state}" in + CLOSED|MERGED) + echo "Deleting wiki preview branch ${branch} for ${state} pull request #${pull_request_number}." + git push origin --delete "${branch}" || true + deleted=$((deleted + 1)) + ;; + OPEN) + echo "Keeping wiki preview branch ${branch} for open pull request #${pull_request_number}." + skipped=$((skipped + 1)) + ;; + *) + echo "Could not resolve pull request #${pull_request_number} for wiki preview branch ${branch}. Keeping it." + unresolved=$((unresolved + 1)) + ;; + esac +done < <(git for-each-ref --format='%(refname:short)' refs/remotes/origin/pr-*) + +echo "deleted=${deleted}" >> "${GITHUB_OUTPUT}" +echo "skipped=${skipped}" >> "${GITHUB_OUTPUT}" +echo "unresolved=${unresolved}" >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/wiki/delete-preview-branch/action.yml b/.github/actions/wiki/delete-preview-branch/action.yml new file mode 100644 index 0000000..c98f4a8 --- /dev/null +++ b/.github/actions/wiki/delete-preview-branch/action.yml @@ -0,0 +1,17 @@ +name: Delete Wiki Preview Branch +description: Delete a wiki preview branch when it exists. + +inputs: + preview-branch: + description: Preview branch name in the wiki repository. + required: true + +runs: + using: composite + steps: + - name: Delete preview branch + shell: bash + env: + INPUT_PREVIEW_BRANCH: ${{ inputs.preview-branch }} + working-directory: .github/wiki + run: ${{ github.action_path }}/run.sh diff --git a/.github/actions/wiki/delete-preview-branch/run.sh b/.github/actions/wiki/delete-preview-branch/run.sh new file mode 100755 index 0000000..505b624 --- /dev/null +++ b/.github/actions/wiki/delete-preview-branch/run.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +preview_branch="${INPUT_PREVIEW_BRANCH}" + +if git ls-remote --exit-code --heads origin "${preview_branch}" >/dev/null 2>&1; then + git push origin --delete "${preview_branch}" +else + echo "Wiki preview branch ${preview_branch} does not exist. Nothing to delete." +fi diff --git a/.github/actions/wiki/prepare-preview-branch/action.yml b/.github/actions/wiki/prepare-preview-branch/action.yml new file mode 100644 index 0000000..d938f23 --- /dev/null +++ b/.github/actions/wiki/prepare-preview-branch/action.yml @@ -0,0 +1,17 @@ +name: Prepare Wiki Preview Branch +description: Prepare the wiki preview branch for a pull request. + +inputs: + preview-branch: + description: Preview branch name in the wiki repository. + required: true + +runs: + using: composite + steps: + - name: Prepare preview branch + shell: bash + env: + INPUT_PREVIEW_BRANCH: ${{ inputs.preview-branch }} + working-directory: .github/wiki + run: ${{ github.action_path }}/run.sh diff --git a/.github/actions/wiki/prepare-preview-branch/run.sh b/.github/actions/wiki/prepare-preview-branch/run.sh new file mode 100755 index 0000000..7a5e0b4 --- /dev/null +++ b/.github/actions/wiki/prepare-preview-branch/run.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +preview_branch="${INPUT_PREVIEW_BRANCH}" + +git fetch origin + +if git ls-remote --exit-code --heads origin "${preview_branch}" >/dev/null 2>&1; then + git switch -C "${preview_branch}" --track "origin/${preview_branch}" + git reset --hard "origin/${preview_branch}" +else + git switch --orphan "${preview_branch}" + git rm -rf . >/dev/null 2>&1 || true + find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + +fi + +git clean -fd diff --git a/.github/actions/wiki/prepare-publish-branch/action.yml b/.github/actions/wiki/prepare-publish-branch/action.yml new file mode 100644 index 0000000..557fb8a --- /dev/null +++ b/.github/actions/wiki/prepare-publish-branch/action.yml @@ -0,0 +1,27 @@ +name: Prepare Wiki Publish Branch +description: Prepare the wiki publish branch from a preview branch. + +inputs: + publish-branch: + description: Publish branch name in the wiki repository. + required: true + preview-branch: + description: Preview branch name in the wiki repository. + required: true + +outputs: + expected-preview-sha: + description: Preview branch SHA expected on the publish branch after push. + value: ${{ steps.prepare.outputs.expected-preview-sha }} + +runs: + using: composite + steps: + - id: prepare + name: Prepare publish branch + shell: bash + env: + INPUT_PUBLISH_BRANCH: ${{ inputs.publish-branch }} + INPUT_PREVIEW_BRANCH: ${{ inputs.preview-branch }} + working-directory: .github/wiki + run: ${{ github.action_path }}/run.sh diff --git a/.github/actions/wiki/prepare-publish-branch/run.sh b/.github/actions/wiki/prepare-publish-branch/run.sh new file mode 100755 index 0000000..2fb8d6b --- /dev/null +++ b/.github/actions/wiki/prepare-publish-branch/run.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +publish_branch="${INPUT_PUBLISH_BRANCH}" +preview_branch="${INPUT_PREVIEW_BRANCH}" + +git fetch origin "${publish_branch}" "${preview_branch}" + +expected_preview_sha="$(git rev-parse "origin/${preview_branch}")" +echo "expected-preview-sha=${expected_preview_sha}" >> "${GITHUB_OUTPUT}" +echo "Expected wiki preview SHA: ${expected_preview_sha}" + +git switch -C "${publish_branch}" --track "origin/${publish_branch}" || git switch "${publish_branch}" +git reset --hard "origin/${preview_branch}" +git clean -fd diff --git a/.github/actions/wiki/validate-publish-branch/action.yml b/.github/actions/wiki/validate-publish-branch/action.yml new file mode 100644 index 0000000..2e3076d --- /dev/null +++ b/.github/actions/wiki/validate-publish-branch/action.yml @@ -0,0 +1,25 @@ +name: Validate Wiki Publish Branch +description: Validate that the wiki publish branch matches the expected preview SHA. + +inputs: + publish-branch: + description: Publish branch name in the wiki repository. + required: true + preview-branch: + description: Preview branch name in the wiki repository. + required: true + expected-preview-sha: + description: Preview branch SHA expected on the publish branch. + required: true + +runs: + using: composite + steps: + - name: Validate publish branch + shell: bash + env: + INPUT_PUBLISH_BRANCH: ${{ inputs.publish-branch }} + INPUT_PREVIEW_BRANCH: ${{ inputs.preview-branch }} + INPUT_EXPECTED_PREVIEW_SHA: ${{ inputs.expected-preview-sha }} + working-directory: .github/wiki + run: ${{ github.action_path }}/run.sh diff --git a/.github/actions/wiki/validate-publish-branch/run.sh b/.github/actions/wiki/validate-publish-branch/run.sh new file mode 100755 index 0000000..ff6cf70 --- /dev/null +++ b/.github/actions/wiki/validate-publish-branch/run.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +publish_branch="${INPUT_PUBLISH_BRANCH}" +preview_branch="${INPUT_PREVIEW_BRANCH}" +expected_preview_sha="${INPUT_EXPECTED_PREVIEW_SHA}" +actual_publish_sha="$(git ls-remote origin "refs/heads/${publish_branch}" | awk '{print $1}')" + +echo "Expected wiki publish SHA: ${expected_preview_sha}" +echo "Actual wiki publish SHA: ${actual_publish_sha}" + +if [ -z "${actual_publish_sha}" ]; then + echo "Remote wiki publish branch ${publish_branch} was not found after push." >&2 + exit 1 +fi + +if [ "${actual_publish_sha}" != "${expected_preview_sha}" ]; then + echo "Remote wiki publish branch ${publish_branch} does not match preview branch ${preview_branch}." >&2 + exit 1 +fi diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 0000000..eb4d4b4 --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,158 @@ +name: Project Board Automation + +on: + workflow_call: + inputs: + project: + description: Optional GitHub Project V2 number for consumer repositories. When omitted, php-fast-forward repositories default to the first organization project. + required: false + type: string + default: '' + issues: + types: [opened, reopened, closed] + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review, converted_to_draft, closed] + pull_request_review: + types: [submitted, dismissed] + +permissions: + contents: read + issues: write + pull-requests: write + repository-projects: write + +env: + FAST_FORWARD_ACTIONS_REPOSITORY: php-fast-forward/.github + FAST_FORWARD_ACTIONS_REF: ${{ github.repository != 'php-fast-forward/.github' && vars.FAST_FORWARD_ACTIONS_REF || '' }} + +jobs: + resolve-project: + runs-on: ubuntu-latest + outputs: + project_number: ${{ steps.resolve.outputs.project-number }} + steps: + - &resolve_shared_action_ref + name: Resolve shared action ref + id: shared_actions + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CURRENT_REF: ${{ github.head_ref || github.ref_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + + if [ -n "${FAST_FORWARD_ACTIONS_REF}" ]; then + ref="${FAST_FORWARD_ACTIONS_REF}" + elif [ "${GITHUB_REPOSITORY}" = "${FAST_FORWARD_ACTIONS_REPOSITORY}" ]; then + if [ "${GITHUB_EVENT_NAME}" = "pull_request_target" ] && [ -n "${BASE_SHA:-}" ]; then + ref="${BASE_SHA}" + else + ref="${CURRENT_REF:-main}" + fi + else + ref="$(gh api "repos/${FAST_FORWARD_ACTIONS_REPOSITORY}/releases/latest" --jq .tag_name 2>/dev/null || true)" + + if [ -z "${ref}" ] || [ "${ref}" = "null" ]; then + ref="main" + fi + fi + + echo "ref=${ref}" >> "${GITHUB_OUTPUT}" + + - &checkout_shared_action_source + name: Checkout shared action source + uses: actions/checkout@v6 + with: + repository: ${{ env.FAST_FORWARD_ACTIONS_REPOSITORY }} + ref: ${{ steps.shared_actions.outputs.ref }} + path: .fast-forward-actions + sparse-checkout: | + .github/actions + + - id: resolve + uses: ./.fast-forward-actions/.github/actions/project-board/resolve + with: + project: ${{ inputs.project || vars.PROJECT || '' }} + + assign-author: + if: ${{ github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') }} + runs-on: ubuntu-latest + steps: + - name: Auto assign PR author + uses: toshimaru/auto-author-assign@v3.0.1 + with: + repo-token: ${{ github.token }} + + sync-issue-status: + needs: resolve-project + if: github.event_name == 'issues' && needs.resolve-project.outputs.project_number != '' + runs-on: ubuntu-latest + steps: + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - uses: ./.fast-forward-actions/.github/actions/project-board/sync-status + with: + organization: ${{ github.repository_owner }} + project: ${{ needs.resolve-project.outputs.project_number }} + resource-node-id: ${{ github.event.issue.node_id }} + status-value: ${{ github.event.action == 'closed' && 'Merged' || 'Backlog' }} + + sync-pull-request-metadata: + needs: resolve-project + if: github.event_name == 'pull_request_target' && needs.resolve-project.outputs.project_number != '' + runs-on: ubuntu-latest + steps: + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - uses: ./.fast-forward-actions/.github/actions/project-board/sync-linked-pr-metadata + + sync-pull-request-status: + needs: resolve-project + if: github.event_name == 'pull_request_target' && needs.resolve-project.outputs.project_number != '' + runs-on: ubuntu-latest + outputs: + linked_issue_node_id: ${{ steps.compute.outputs.linked-issue-node-id }} + linked_issue_status: ${{ steps.compute.outputs.linked-issue-status }} + pull_request_status: ${{ steps.compute.outputs.pull-request-status }} + steps: + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - id: compute + uses: ./.fast-forward-actions/.github/actions/project-board/resolve-pr-status + + - uses: ./.fast-forward-actions/.github/actions/project-board/sync-status + with: + organization: ${{ github.repository_owner }} + project: ${{ needs.resolve-project.outputs.project_number }} + resource-node-id: ${{ github.event.pull_request.node_id }} + status-value: ${{ steps.compute.outputs.pull-request-status }} + + - if: steps.compute.outputs.linked-issue-node-id != '' && steps.compute.outputs.linked-issue-status != '' + uses: ./.fast-forward-actions/.github/actions/project-board/sync-status + with: + organization: ${{ github.repository_owner }} + project: ${{ needs.resolve-project.outputs.project_number }} + resource-node-id: ${{ steps.compute.outputs.linked-issue-node-id }} + status-value: ${{ steps.compute.outputs.linked-issue-status }} + + sync-review-state: + needs: resolve-project + if: github.event_name == 'pull_request_review' && needs.resolve-project.outputs.project_number != '' + runs-on: ubuntu-latest + steps: + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - id: compute + uses: ./.fast-forward-actions/.github/actions/project-board/resolve-review-status + + - uses: ./.fast-forward-actions/.github/actions/project-board/sync-status + with: + organization: ${{ github.repository_owner }} + project: ${{ needs.resolve-project.outputs.project_number }} + resource-node-id: ${{ github.event.pull_request.node_id }} + status-value: ${{ steps.compute.outputs.status }} diff --git a/.github/workflows/auto-resolve-conflicts.yml b/.github/workflows/auto-resolve-conflicts.yml new file mode 100644 index 0000000..22885e2 --- /dev/null +++ b/.github/workflows/auto-resolve-conflicts.yml @@ -0,0 +1,110 @@ +name: Auto-resolve Predictable Conflicts + +on: + workflow_call: + inputs: + base-ref: + description: Base branch inspected for open pull requests. + required: false + type: string + default: main + pull-request-number: + description: Optional pull request number to inspect. + required: false + type: string + default: '' + workflow_dispatch: + inputs: + base-ref: + description: Base branch inspected for open pull requests. + required: false + type: string + default: main + pull-request-number: + description: Optional pull request number to inspect. Leave empty to scan open pull requests targeting the base branch. + required: false + type: string + default: '' + push: + branches: [ "main" ] + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + +permissions: + actions: write + contents: write + pull-requests: write + +concurrency: + group: ${{ github.event_name == 'pull_request' && format('auto-resolve-conflicts-pr-{0}', github.event.pull_request.number) || format('auto-resolve-conflicts-{0}', github.ref) }} + cancel-in-progress: true + +env: + FORCE_COLOR: '1' + FAST_FORWARD_ACTIONS_REPOSITORY: php-fast-forward/.github + FAST_FORWARD_ACTIONS_REF: ${{ github.repository != 'php-fast-forward/.github' && vars.FAST_FORWARD_ACTIONS_REF || '' }} + +jobs: + resolve_predictable_conflicts: + name: Resolve Predictable Conflicts + runs-on: ubuntu-latest + env: + BASE_REF: ${{ inputs.base-ref || github.event.pull_request.base.ref || github.event.repository.default_branch || 'main' }} + PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number || github.event.pull_request.number || '' }} + AUTO_RESOLVE_ROOT_VERSION: ${{ github.event_name == 'pull_request' && format('dev-{0}', github.event.pull_request.head.ref) || 'dev-main' }} + GH_TOKEN: ${{ github.token }} + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Resolve shared action ref + id: shared_actions + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CURRENT_REF: ${{ github.head_ref || github.ref_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + + if [ -n "${FAST_FORWARD_ACTIONS_REF}" ]; then + ref="${FAST_FORWARD_ACTIONS_REF}" + elif [ "${GITHUB_REPOSITORY}" = "${FAST_FORWARD_ACTIONS_REPOSITORY}" ]; then + if [ "${GITHUB_EVENT_NAME}" = "pull_request_target" ] && [ -n "${BASE_SHA:-}" ]; then + ref="${BASE_SHA}" + else + ref="${CURRENT_REF:-main}" + fi + else + ref="$(gh api "repos/${FAST_FORWARD_ACTIONS_REPOSITORY}/releases/latest" --jq .tag_name 2>/dev/null || true)" + + if [ -z "${ref}" ] || [ "${ref}" = "null" ]; then + ref="main" + fi + fi + + echo "ref=${ref}" >> "${GITHUB_OUTPUT}" + + - name: Checkout shared action source + uses: actions/checkout@v6 + with: + repository: ${{ env.FAST_FORWARD_ACTIONS_REPOSITORY }} + ref: ${{ steps.shared_actions.outputs.ref }} + path: .fast-forward-actions + sparse-checkout: | + .github/actions + + - name: Setup PHP and install dependencies + uses: ./.fast-forward-actions/.github/actions/php/setup-composer + with: + php-version: '8.3' + root-version: ${{ env.AUTO_RESOLVE_ROOT_VERSION }} + install-options: --prefer-dist --no-progress --no-interaction --no-plugins --no-scripts + + - name: Resolve predictable pull request conflicts + uses: ./.fast-forward-actions/.github/actions/github/resolve-predictable-conflicts + with: + base-ref: ${{ env.BASE_REF }} + pull-request-number: ${{ env.PULL_REQUEST_NUMBER }} diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..bd4a65e --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,397 @@ +name: Changelog Automation + +on: + workflow_call: + inputs: + changelog-file: + description: Path to the managed changelog file. + required: false + type: string + default: CHANGELOG.md + dev-tools-version: + description: Composer version constraint or branch used when globally installing fast-forward/dev-tools. + required: false + type: string + default: ^1.0 + project: + description: Optional GitHub Project V2 number for consumer repositories. When omitted, php-fast-forward repositories default to the first organization project. + required: false + type: string + default: '' + version: + description: Optional version to promote during manual release preparation. + required: false + type: string + default: '' + release-branch-prefix: + description: Prefix used for release-preparation branches. + required: false + type: string + default: release/v + workflow_dispatch: + inputs: + changelog-file: + description: Path to the managed changelog file. + required: false + type: string + default: CHANGELOG.md + dev-tools-version: + description: Composer version constraint or branch used when globally installing fast-forward/dev-tools. + required: false + type: string + default: ^1.0 + project: + description: Optional GitHub Project V2 number for consumer repositories. Leave empty to use the configured repository variable or the php-fast-forward default. + required: false + type: string + default: '' + version: + description: Optional version to promote. Leave empty to infer from Unreleased. + required: false + type: string + default: '' + release-branch-prefix: + description: Prefix used for release-preparation branches. + required: false + type: string + default: release/v + pull_request: + types: [opened, reopened, synchronize] + pull_request_target: + types: [closed] + +permissions: + actions: write + contents: write + pull-requests: write + repository-projects: write + +concurrency: + group: ${{ github.event.pull_request.number && format('changelog-pr-{0}', github.event.pull_request.number) || format('changelog-{0}', github.ref) }} + cancel-in-progress: ${{ github.event.pull_request.merged != true }} + +env: + FORCE_COLOR: '1' + FAST_FORWARD_ACTIONS_REPOSITORY: php-fast-forward/.github + FAST_FORWARD_ACTIONS_REF: ${{ github.repository != 'php-fast-forward/.github' && vars.FAST_FORWARD_ACTIONS_REF || '' }} + +jobs: + resolve_php: + name: Resolve PHP Version + runs-on: ubuntu-latest + outputs: + php-version: ${{ steps.resolve.outputs.php-version }} + + steps: + - uses: actions/checkout@v6 + + - &resolve_shared_action_ref + name: Resolve shared action ref + id: shared_actions + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CURRENT_REF: ${{ github.head_ref || github.ref_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + + if [ -n "${FAST_FORWARD_ACTIONS_REF}" ]; then + ref="${FAST_FORWARD_ACTIONS_REF}" + elif [ "${GITHUB_REPOSITORY}" = "${FAST_FORWARD_ACTIONS_REPOSITORY}" ]; then + if [ "${GITHUB_EVENT_NAME}" = "pull_request_target" ] && [ -n "${BASE_SHA:-}" ]; then + ref="${BASE_SHA}" + else + ref="${CURRENT_REF:-main}" + fi + else + ref="$(gh api "repos/${FAST_FORWARD_ACTIONS_REPOSITORY}/releases/latest" --jq .tag_name 2>/dev/null || true)" + + if [ -z "${ref}" ] || [ "${ref}" = "null" ]; then + ref="main" + fi + fi + + echo "ref=${ref}" >> "${GITHUB_OUTPUT}" + + - &checkout_shared_action_source + name: Checkout shared action source + uses: actions/checkout@v6 + with: + repository: ${{ env.FAST_FORWARD_ACTIONS_REPOSITORY }} + ref: ${{ steps.shared_actions.outputs.ref }} + path: .fast-forward-actions + sparse-checkout: | + .github/actions + + - name: Resolve workflow PHP version + id: resolve + uses: ./.fast-forward-actions/.github/actions/php/resolve-version + + validate_pull_request: + name: Validate PR Changelog + needs: resolve_php + if: ${{ github.event.pull_request.number && github.event.pull_request.merged != true && !startsWith(github.event.pull_request.head.ref, inputs.release-branch-prefix || 'release/v') }} + runs-on: ubuntu-latest + env: + CHANGELOG_FILE: ${{ inputs.changelog-file || 'CHANGELOG.md' }} + CHANGELOG_ROOT_VERSION: ${{ github.event.pull_request.head.ref && format('dev-{0}', github.event.pull_request.head.ref) || 'dev-main' }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + PULL_REQUEST_TITLE: ${{ github.event.pull_request.title }} + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - name: Setup PHP and install dependencies + uses: ./.fast-forward-actions/.github/actions/php/setup-composer + with: + 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 + + - name: Setup Fast Forward DevTools + uses: ./.fast-forward-actions/.github/actions/dev-tools/setup + with: + version: ${{ inputs.dev-tools-version || '^1.0' }} + + - name: Fetch base branch reference + run: git fetch --no-tags --depth=1 origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" + + - name: Create Dependabot changelog entry when missing + id: dependabot_entry + if: ${{ (github.event.pull_request.user.login == 'dependabot[bot]' || github.event.pull_request.user.login == 'app/dependabot' || startsWith(github.event.pull_request.head.ref, 'dependabot/')) && github.event.pull_request.head.repo.full_name == github.repository }} + uses: ./.fast-forward-actions/.github/actions/changelog/create-dependabot-entry + with: + changelog-file: ${{ env.CHANGELOG_FILE }} + base-ref: ${{ env.BASE_REF }} + head-ref: ${{ env.HEAD_REF }} + pull-request-number: ${{ env.PULL_REQUEST_NUMBER }} + pull-request-title: ${{ env.PULL_REQUEST_TITLE }} + + - name: Verify changelog update + run: | + "${DEV_TOOLS_BIN}" changelog:check --file="${CHANGELOG_FILE}" --against="origin/${BASE_REF}" + + - uses: ./.fast-forward-actions/.github/actions/summary/write + with: + markdown: | + ## Changelog Validation Summary + + - Changelog file: `${{ env.CHANGELOG_FILE }}` + - Compared base ref: `origin/${{ env.BASE_REF }}` + - Dependabot fallback status: `${{ steps.dependabot_entry.outputs.status || 'not needed' }}` + - Dependabot fallback entry created: `${{ steps.dependabot_entry.outputs.created || 'false' }}` + - Dependabot fallback generated message: `${{ steps.dependabot_entry.outputs.message || 'not needed' }}` + - Validation result: success + + changelog_validation: + name: Changelog Validation + needs: + - resolve_php + - validate_pull_request + if: ${{ always() && github.event.pull_request.number && github.event.pull_request.merged != true }} + runs-on: ubuntu-latest + env: + RELEASE_BRANCH_PREFIX: ${{ inputs.release-branch-prefix || 'release/v' }} + VALIDATION_RESULT: ${{ needs.validate_pull_request.result }} + + steps: + - name: Require changelog validation result + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + if [[ "${HEAD_REF}" == "${RELEASE_BRANCH_PREFIX}"* ]]; then + echo "Release preparation branch detected; changelog validation is intentionally skipped." + exit 0 + fi + + if [ "${VALIDATION_RESULT}" = "success" ]; then + echo "Changelog validation passed." + exit 0 + fi + + echo "::error::Changelog validation did not pass for this pull request." + echo "Validation result: ${VALIDATION_RESULT}" + exit 1 + + prepare_release_pull_request: + name: Prepare Release Pull Request + needs: resolve_php + if: ${{ !github.event.pull_request.number }} + runs-on: ubuntu-latest + env: + CHANGELOG_FILE: ${{ inputs.changelog-file || 'CHANGELOG.md' }} + RELEASE_BRANCH_PREFIX: ${{ inputs.release-branch-prefix || 'release/v' }} + CHANGELOG_ROOT_VERSION: ${{ format('dev-{0}', github.event.repository.default_branch) }} + + steps: + - uses: actions/checkout@v6 + with: + token: ${{ github.token }} + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - name: Setup PHP and install dependencies + uses: ./.fast-forward-actions/.github/actions/php/setup-composer + with: + 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 + + - name: Setup Fast Forward DevTools + uses: ./.fast-forward-actions/.github/actions/dev-tools/setup + with: + version: ${{ inputs.dev-tools-version || '^1.0' }} + + - name: Resolve release version + id: version + uses: ./.fast-forward-actions/.github/actions/changelog/resolve-version + with: + changelog-file: ${{ env.CHANGELOG_FILE }} + version: ${{ inputs.version || '' }} + + - name: Promote changelog release + env: + RELEASE_VERSION: ${{ steps.version.outputs.value }} + run: | + release_date="$(date -u +%F)" + "${DEV_TOOLS_BIN}" changelog:promote "${RELEASE_VERSION}" --file="${CHANGELOG_FILE}" --date="${release_date}" + + - name: Render release notes preview + uses: ./.fast-forward-actions/.github/actions/changelog/render-release-notes + with: + changelog-file: ${{ env.CHANGELOG_FILE }} + version: ${{ steps.version.outputs.value }} + + - name: Create release preparation pull request + id: create_pr + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ github.token }} + branch: ${{ env.RELEASE_BRANCH_PREFIX }}${{ steps.version.outputs.value }} + delete-branch: false + base: ${{ github.event.repository.default_branch }} + add-paths: | + ${{ env.CHANGELOG_FILE }} + commit-message: Prepare release v${{ steps.version.outputs.value }} + title: Prepare release v${{ steps.version.outputs.value }} + body: | + ## Summary + + - promote `Unreleased` into `${{ steps.version.outputs.value }}` + - prepare the GitHub release body from `${{ env.CHANGELOG_FILE }}` + - reusable workflows resolve shared-action source refs without requiring direct writes to the protected default branch + + ## Version Resolution + + - source: `${{ steps.version.outputs.source }}` + + - name: Dispatch required tests for release pull request + if: ${{ steps.create_pr.outputs.pull-request-number != '' }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: ${{ env.RELEASE_BRANCH_PREFIX }}${{ steps.version.outputs.value }} + run: gh workflow run tests.yml --ref "${RELEASE_BRANCH}" -f publish-required-statuses=true + + - uses: actions/checkout@v6 + - *checkout_shared_action_source + - uses: ./.fast-forward-actions/.github/actions/project-board/transition-status + with: + project: ${{ inputs.project || vars.PROJECT || '' }} + from-status: Merged + to-status: Release Prepared + + - uses: ./.fast-forward-actions/.github/actions/summary/write + with: + markdown: | + ## Release Preparation Summary + + - Changelog file: `${{ env.CHANGELOG_FILE }}` + - Release version: `${{ steps.version.outputs.value }}` + - Version source: `${{ steps.version.outputs.source }}` + - Pull request operation: `${{ steps.create_pr.outputs.pull-request-operation || 'none' }}` + - Pull request URL: ${{ steps.create_pr.outputs.pull-request-url || 'not created' }} + - Required test dispatch: `${{ steps.create_pr.outputs.pull-request-number != '' && 'requested' || 'not needed' }}` + + publish_merged_release: + name: Publish Merged Release + needs: resolve_php + if: ${{ github.event.pull_request.merged == true && github.event.pull_request.base.ref == github.event.repository.default_branch && github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.event.pull_request.head.ref, inputs.release-branch-prefix || 'release/v') }} + runs-on: ubuntu-latest + env: + CHANGELOG_FILE: ${{ inputs.changelog-file || 'CHANGELOG.md' }} + RELEASE_BRANCH_PREFIX: ${{ inputs.release-branch-prefix || 'release/v' }} + CHANGELOG_ROOT_VERSION: ${{ format('dev-{0}', github.event.repository.default_branch) }} + + steps: + - uses: actions/checkout@v6 + with: + token: ${{ github.token }} + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 0 + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - name: Setup PHP and install dependencies + uses: ./.fast-forward-actions/.github/actions/php/setup-composer + with: + 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 + + - name: Setup Fast Forward DevTools + uses: ./.fast-forward-actions/.github/actions/dev-tools/setup + with: + version: ${{ inputs.dev-tools-version || '^1.0' }} + + - name: Resolve merged release version + id: version + uses: ./.fast-forward-actions/.github/actions/changelog/resolve-merged-version + with: + head-ref: ${{ github.event.pull_request.head.ref }} + release-branch-prefix: ${{ env.RELEASE_BRANCH_PREFIX }} + + - name: Render release notes + uses: ./.fast-forward-actions/.github/actions/changelog/render-release-notes + with: + changelog-file: ${{ env.CHANGELOG_FILE }} + version: ${{ steps.version.outputs.value }} + + - name: Publish GitHub release + id: publish_release + uses: ./.fast-forward-actions/.github/actions/changelog/publish-release + with: + version: ${{ steps.version.outputs.value }} + target: ${{ github.event.pull_request.merge_commit_sha || github.sha }} + + - uses: actions/checkout@v6 + - *checkout_shared_action_source + - id: release_project_status + uses: ./.fast-forward-actions/.github/actions/project-board/transition-status + with: + project: ${{ inputs.project || vars.PROJECT || '' }} + from-statuses: Release Prepared,Merged + to-status: Released + include-current-pull-request: 'true' + + - uses: ./.fast-forward-actions/.github/actions/summary/write + with: + markdown: | + ## Release Publication Summary + + - Changelog file: `${{ env.CHANGELOG_FILE }}` + - Published tag: `v${{ steps.version.outputs.value }}` + - Shared action ref source: `${{ steps.shared_actions.outputs.ref }}` + - Release operation: `${{ steps.publish_release.outputs.operation }}` + - Release URL: ${{ steps.publish_release.outputs.url }} + - 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/label-sync.yml b/.github/workflows/label-sync.yml new file mode 100644 index 0000000..f8b2740 --- /dev/null +++ b/.github/workflows/label-sync.yml @@ -0,0 +1,70 @@ +name: Pull Request Label Sync + +on: + pull_request_target: + types: [opened, reopened, synchronize] + pull_request: + types: [opened, reopened, synchronize] + workflow_call: + inputs: + copy_issue_labels: + type: boolean + default: true + description: "Copy labels from linked issue" + +permissions: + contents: read + issues: read + pull-requests: write + +env: + FAST_FORWARD_ACTIONS_REPOSITORY: php-fast-forward/.github + FAST_FORWARD_ACTIONS_REF: ${{ github.repository != 'php-fast-forward/.github' && vars.FAST_FORWARD_ACTIONS_REF || '' }} + +jobs: + copy-issue-labels: + if: inputs.copy_issue_labels == true || github.event_name != 'workflow_call' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Resolve shared action ref + id: shared_actions + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CURRENT_REF: ${{ github.head_ref || github.ref_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + + if [ -n "${FAST_FORWARD_ACTIONS_REF}" ]; then + ref="${FAST_FORWARD_ACTIONS_REF}" + elif [ "${GITHUB_REPOSITORY}" = "${FAST_FORWARD_ACTIONS_REPOSITORY}" ]; then + if [ "${GITHUB_EVENT_NAME}" = "pull_request_target" ] && [ -n "${BASE_SHA:-}" ]; then + ref="${BASE_SHA}" + else + ref="${CURRENT_REF:-main}" + fi + else + ref="$(gh api "repos/${FAST_FORWARD_ACTIONS_REPOSITORY}/releases/latest" --jq .tag_name 2>/dev/null || true)" + + if [ -z "${ref}" ] || [ "${ref}" = "null" ]; then + ref="main" + fi + fi + + echo "ref=${ref}" >> "${GITHUB_OUTPUT}" + + - name: Checkout shared action source + uses: actions/checkout@v6 + with: + repository: ${{ env.FAST_FORWARD_ACTIONS_REPOSITORY }} + ref: ${{ steps.shared_actions.outputs.ref }} + path: .fast-forward-actions + sparse-checkout: | + .github/actions + + - name: Copy labels from issue to PR + uses: ./.fast-forward-actions/.github/actions/label-sync/copy-linked-issue-labels diff --git a/.github/workflows/reports.yml b/.github/workflows/reports.yml new file mode 100644 index 0000000..1d56909 --- /dev/null +++ b/.github/workflows/reports.yml @@ -0,0 +1,387 @@ +name: Generate Reports and Deploy to GitHub Pages + +on: + workflow_call: + inputs: + cleanup-previews: + description: Remove stale pull request previews without publishing production reports. + required: false + type: boolean + default: false + dev-tools-version: + description: Composer version constraint or branch used when globally installing fast-forward/dev-tools. + required: false + type: string + default: ^1.0 + workflow_dispatch: + inputs: + cleanup-previews: + description: Remove stale pull request previews without publishing production reports. + required: false + type: boolean + default: false + dev-tools-version: + description: Composer version constraint or branch used when globally installing fast-forward/dev-tools. + required: false + type: string + default: ^1.0 + schedule: + - cron: '41 3 * * *' + pull_request: + types: [ "opened", "synchronize", "reopened", "closed" ] + push: + branches: [ "main" ] + +permissions: + contents: read + +concurrency: + group: ${{ github.event_name == 'pull_request' && format('reports-preview-pr-{0}', github.event.pull_request.number) || 'reports-pages' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.action != 'closed' }} + +env: + FORCE_COLOR: '1' + FAST_FORWARD_ACTIONS_REPOSITORY: php-fast-forward/.github + FAST_FORWARD_ACTIONS_REF: ${{ github.repository != 'php-fast-forward/.github' && vars.FAST_FORWARD_ACTIONS_REF || '' }} + +jobs: + resolve_php: + name: Resolve PHP Version + runs-on: ubuntu-latest + outputs: + php-version: ${{ steps.resolve.outputs.php-version }} + php-version-source: ${{ steps.resolve.outputs.php-version-source }} + repository-has-composer-json: ${{ steps.detect.outputs.composer-json }} + repository-has-docs-source: ${{ steps.detect.outputs.docs-source }} + repository-has-php-files: ${{ steps.detect.outputs.php-files }} + repository-has-phpunit-config: ${{ steps.detect.outputs.phpunit-config }} + repository-has-test-files: ${{ steps.detect.outputs.test-files }} + repository-is-reportable: ${{ steps.detect.outputs.reportable }} + + steps: + - uses: actions/checkout@v6 + - &resolve_shared_action_ref + name: Resolve shared action ref + id: shared_actions + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CURRENT_REF: ${{ github.head_ref || github.ref_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + + if [ -n "${FAST_FORWARD_ACTIONS_REF}" ]; then + ref="${FAST_FORWARD_ACTIONS_REF}" + elif [ "${GITHUB_REPOSITORY}" = "${FAST_FORWARD_ACTIONS_REPOSITORY}" ]; then + if [ "${GITHUB_EVENT_NAME}" = "pull_request_target" ] && [ -n "${BASE_SHA:-}" ]; then + ref="${BASE_SHA}" + else + ref="${CURRENT_REF:-main}" + fi + else + ref="$(gh api "repos/${FAST_FORWARD_ACTIONS_REPOSITORY}/releases/latest" --jq .tag_name 2>/dev/null || true)" + + if [ -z "${ref}" ] || [ "${ref}" = "null" ]; then + ref="main" + fi + fi + + echo "ref=${ref}" >> "${GITHUB_OUTPUT}" + + - &checkout_shared_action_source + name: Checkout shared action source + uses: actions/checkout@v6 + with: + repository: ${{ env.FAST_FORWARD_ACTIONS_REPOSITORY }} + ref: ${{ steps.shared_actions.outputs.ref }} + path: .fast-forward-actions + sparse-checkout: | + .github/actions + + - name: Resolve workflow PHP version + id: resolve + uses: ./.fast-forward-actions/.github/actions/php/resolve-version + + - name: Detect PHP project report surface + id: detect + uses: ./.fast-forward-actions/.github/actions/php/detect-project + + reports: + needs: resolve_php + if: github.event_name != 'schedule' && !(github.event_name == 'workflow_dispatch' && inputs.cleanup-previews) && (github.event_name != 'pull_request' || github.event.action != 'closed') && needs.resolve_php.outputs.repository-is-reportable == 'true' + name: Generate Reports + runs-on: ubuntu-latest + permissions: + contents: write + + env: + REPORTS_TARGET: .dev-tools + REPORTS_ROOT_VERSION: ${{ github.event_name == 'pull_request' && format('dev-{0}', github.event.pull_request.head.ref) || 'dev-main' }} + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - name: Setup PHP and install dependencies + uses: ./.fast-forward-actions/.github/actions/php/setup-composer + with: + php-version: ${{ needs.resolve_php.outputs.php-version }} + extensions: pcov, pcntl + coverage: pcov + root-version: ${{ env.REPORTS_ROOT_VERSION }} + + - name: Setup Fast Forward DevTools + uses: ./.fast-forward-actions/.github/actions/dev-tools/setup + with: + version: ${{ inputs.dev-tools-version || '^1.0' }} + + - name: Generate reports + env: + COMPOSER_ROOT_VERSION: ${{ env.REPORTS_ROOT_VERSION }} + run: | + "${DEV_TOOLS_BIN}" reports --target="${REPORTS_TARGET}" --coverage="${REPORTS_TARGET}/coverage" --metrics="${REPORTS_TARGET}/metrics" + + - name: Fix permissions + run: | + chmod -c -R +rX "${REPORTS_TARGET}/" | while IFS= read -r line; do + echo "::warning title=Invalid file permissions automatically fixed::$line" + done + + - name: Add .nojekyll + run: touch "${REPORTS_TARGET}/.nojekyll" + + - name: Remove repository-local caches from published output + run: rm -rf "${REPORTS_TARGET}/cache" + + - name: Restore previews from gh-pages + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + uses: actions/checkout@v6 + continue-on-error: true + with: + ref: gh-pages + path: gh-pages-current + + - name: Copy existing previews into publish directory + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + uses: ./.fast-forward-actions/.github/actions/github-pages/restore-previews + with: + source: gh-pages-current + target: ${{ env.REPORTS_TARGET }} + + - name: Deploy main reports + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: ./${{ env.REPORTS_TARGET }}/ + destination_dir: . + keep_files: false + force_orphan: false + + - name: Deploy PR preview + if: github.event_name == 'pull_request' + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: ./${{ env.REPORTS_TARGET }}/ + destination_dir: previews/pr-${{ github.event.pull_request.number }} + keep_files: false + force_orphan: false + + verify_main_reports: + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && !(github.event_name == 'workflow_dispatch' && inputs.cleanup-previews) + name: Verify Main Reports Deployment + needs: reports + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - uses: ./.fast-forward-actions/.github/actions/github-pages/verify-deployment + with: + base-url: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }} + title: Reports health check failed + checks: | + /|Reports index + /coverage/|Coverage report + /metrics/|Metrics report + + verify_preview_reports: + if: github.event_name == 'pull_request' && github.event.action != 'closed' + name: Verify Pull Request Reports Preview + needs: reports + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - uses: ./.fast-forward-actions/.github/actions/github-pages/verify-deployment + with: + base-url: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/previews/pr-${{ github.event.pull_request.number }} + title: Preview health check failed + checks: | + /|Preview reports index + /coverage/|Preview coverage report + /metrics/|Preview metrics report + + comment_preview: + if: github.event_name == 'pull_request' && github.event.action != 'closed' + name: Comment Pull Request Preview URLs + needs: + - reports + - verify_preview_reports + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Comment preview URLs on pull request + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: pr-preview + message: | + 🚀 Preview is available for this pull request. + + - Docs: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/previews/pr-${{ github.event.pull_request.number }}/ + - Coverage: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/previews/pr-${{ github.event.pull_request.number }}/coverage/ + - Metrics: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/previews/pr-${{ github.event.pull_request.number }}/metrics/ + + cleanup_preview: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + name: Cleanup Pull Request Preview + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + - name: Checkout gh-pages + uses: actions/checkout@v6 + with: + ref: gh-pages + path: gh-pages + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - uses: ./.fast-forward-actions/.github/actions/github-pages/remove-preview + with: + path: gh-pages + pull-request-number: ${{ github.event.pull_request.number }} + + - name: Push changes + run: | + cd gh-pages + git push + + cleanup_orphaned_previews: + if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.cleanup-previews) + name: Cleanup Orphaned Pull Request Previews + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + outputs: + deleted: ${{ steps.cleanup.outputs.deleted }} + skipped: ${{ steps.cleanup.outputs.skipped }} + unresolved: ${{ steps.cleanup.outputs.unresolved }} + + steps: + - uses: actions/checkout@v6 + - name: Checkout gh-pages + uses: actions/checkout@v6 + with: + ref: gh-pages + path: gh-pages + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - uses: ./.fast-forward-actions/.github/actions/github-pages/cleanup-orphaned-previews + id: cleanup + with: + path: gh-pages + + - name: Push changes + run: | + cd gh-pages + git push + + summarize: + if: ${{ always() }} + name: Summarize Reports Workflow + needs: + - resolve_php + - reports + - verify_main_reports + - verify_preview_reports + - cleanup_preview + - cleanup_orphaned_previews + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - id: build_summary + env: + TRIGGER_LABEL: ${{ github.event_name }}${{ github.event.action && format(':{0}', github.event.action) || '' }} + run: | + { + echo 'markdown<> "$GITHUB_OUTPUT" + + - uses: ./.fast-forward-actions/.github/actions/summary/write + with: + markdown: ${{ steps.build_summary.outputs.markdown }} diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml new file mode 100644 index 0000000..1f879a4 --- /dev/null +++ b/.github/workflows/review.yml @@ -0,0 +1,87 @@ +name: Rigorous Pull Request Review + +on: + workflow_call: + inputs: + pull-request-number: + description: Pull request number to review when the workflow is called manually or through a consumer wrapper. + required: false + type: string + default: '' + pull_request_target: + types: [ready_for_review] + workflow_dispatch: + inputs: + pull-request-number: + description: Pull request number to review manually. + required: true + type: string + +permissions: + contents: read + pull-requests: write + +env: + FAST_FORWARD_ACTIONS_REPOSITORY: php-fast-forward/.github + FAST_FORWARD_ACTIONS_REF: ${{ github.repository != 'php-fast-forward/.github' && vars.FAST_FORWARD_ACTIONS_REF || '' }} + +jobs: + request-rigorous-review: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - name: Resolve shared action ref + id: shared_actions + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CURRENT_REF: ${{ github.head_ref || github.ref_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + + if [ -n "${FAST_FORWARD_ACTIONS_REF}" ]; then + ref="${FAST_FORWARD_ACTIONS_REF}" + elif [ "${GITHUB_REPOSITORY}" = "${FAST_FORWARD_ACTIONS_REPOSITORY}" ]; then + if [ "${GITHUB_EVENT_NAME}" = "pull_request_target" ] && [ -n "${BASE_SHA:-}" ]; then + ref="${BASE_SHA}" + else + ref="${CURRENT_REF:-main}" + fi + else + ref="$(gh api "repos/${FAST_FORWARD_ACTIONS_REPOSITORY}/releases/latest" --jq .tag_name 2>/dev/null || true)" + + if [ -z "${ref}" ] || [ "${ref}" = "null" ]; then + ref="main" + fi + fi + + echo "ref=${ref}" >> "${GITHUB_OUTPUT}" + + - name: Checkout shared action source + uses: actions/checkout@v6 + with: + repository: ${{ env.FAST_FORWARD_ACTIONS_REPOSITORY }} + ref: ${{ steps.shared_actions.outputs.ref }} + path: .fast-forward-actions + sparse-checkout: | + .github/actions + + - id: render + uses: ./.fast-forward-actions/.github/actions/review/render-request + with: + pull-request-number: ${{ inputs.pull-request-number || github.event.inputs.pull-request-number || github.event.pull_request.number || '' }} + + - name: Write step summary + env: + REVIEW_SUMMARY: ${{ steps.render.outputs.summary }} + run: | + printf '%s\n' "$REVIEW_SUMMARY" >> "$GITHUB_STEP_SUMMARY" + + - name: Comment review request on pull request + uses: marocchino/sticky-pull-request-comment@v3 + with: + number: ${{ steps.render.outputs.pull-request-number }} + header: rigorous-review + message: ${{ steps.render.outputs.comment }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f70d7bf --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,298 @@ +name: Run PHPUnit Tests + +on: + workflow_call: + inputs: + dev-tools-version: + description: Composer version constraint or branch used when globally installing fast-forward/dev-tools. + required: false + type: string + default: ^1.0 + github-actions-version: + description: Composer version constraint or branch used when globally installing fast-forward/github-actions. + required: false + type: string + default: dev-main + min-coverage: + description: Minimum line coverage percentage enforced by dev-tools tests. + required: false + type: number + default: 80 + max-outdated: + description: Maximum number of outdated packages allowed by the dependencies command. + required: false + type: string + default: '-1' + publish-required-statuses: + description: Mirror required test matrix checks as commit statuses for workflow-dispatched runs. + required: false + type: boolean + default: false + workflow_dispatch: + inputs: + dev-tools-version: + description: Composer version constraint or branch used when globally installing fast-forward/dev-tools. + required: false + type: string + default: ^1.0 + github-actions-version: + description: Composer version constraint or branch used when globally installing fast-forward/github-actions. + required: false + type: string + default: dev-main + min-coverage: + description: Minimum line coverage percentage enforced by dev-tools tests. + required: false + type: number + default: 80 + max-outdated: + description: Maximum number of outdated packages allowed by the dependencies command. + required: false + type: string + default: '-1' + publish-required-statuses: + description: Mirror required test matrix checks as commit statuses for workflow-dispatched runs. + required: false + type: boolean + default: false + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [ "main" ] + +permissions: + contents: read + statuses: write + +concurrency: + group: ${{ github.event_name == 'pull_request' && format('tests-pr-{0}', github.event.pull_request.number) || format('tests-{0}', github.ref) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + FORCE_COLOR: '1' + FAST_FORWARD_ACTIONS_REPOSITORY: php-fast-forward/.github + FAST_FORWARD_ACTIONS_REF: ${{ github.repository != 'php-fast-forward/.github' && vars.FAST_FORWARD_ACTIONS_REF || '' }} + +jobs: + resolve_php: + name: Resolve PHP Version + runs-on: ubuntu-latest + outputs: + php-version: ${{ steps.resolve.outputs.php-version }} + php-version-source: ${{ steps.resolve.outputs.php-version-source }} + repository-has-composer-json: ${{ steps.detect.outputs.composer-json }} + repository-has-php-files: ${{ steps.detect.outputs.php-files }} + repository-has-phpunit-config: ${{ steps.detect.outputs.phpunit-config }} + repository-has-test-files: ${{ steps.detect.outputs.test-files }} + repository-is-testable: ${{ steps.detect.outputs.testable }} + test-matrix: ${{ steps.resolve.outputs.test-matrix }} + + steps: + - uses: actions/checkout@v6 + - &resolve_shared_action_ref + name: Resolve shared action ref + id: shared_actions + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CURRENT_REF: ${{ github.head_ref || github.ref_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + + if [ -n "${FAST_FORWARD_ACTIONS_REF}" ]; then + ref="${FAST_FORWARD_ACTIONS_REF}" + elif [ "${GITHUB_REPOSITORY}" = "${FAST_FORWARD_ACTIONS_REPOSITORY}" ]; then + if [ "${GITHUB_EVENT_NAME}" = "pull_request_target" ] && [ -n "${BASE_SHA:-}" ]; then + ref="${BASE_SHA}" + else + ref="${CURRENT_REF:-main}" + fi + else + ref="$(gh api "repos/${FAST_FORWARD_ACTIONS_REPOSITORY}/releases/latest" --jq .tag_name 2>/dev/null || true)" + + if [ -z "${ref}" ] || [ "${ref}" = "null" ]; then + ref="main" + fi + fi + + echo "ref=${ref}" >> "${GITHUB_OUTPUT}" + + - &checkout_shared_action_source + name: Checkout shared action source + uses: actions/checkout@v6 + with: + repository: ${{ env.FAST_FORWARD_ACTIONS_REPOSITORY }} + ref: ${{ steps.shared_actions.outputs.ref }} + path: .fast-forward-actions + sparse-checkout: | + .github/actions + + - name: Setup Fast Forward GitHub Actions runtime + uses: ./.fast-forward-actions/.github/actions/github-actions/setup + with: + version: ${{ vars.FAST_FORWARD_GITHUB_ACTIONS_VERSION || inputs.github-actions-version || 'dev-main' }} + + - name: Resolve workflow PHP version + id: resolve + uses: ./.fast-forward-actions/.github/actions/php/resolve-version + + - name: Detect PHP project test surface + id: detect + uses: ./.fast-forward-actions/.github/actions/php/detect-project + + tests: + needs: resolve_php + if: needs.resolve_php.outputs.repository-is-testable == 'true' + name: Run Tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.resolve_php.outputs.test-matrix) }} + env: + TESTS_ROOT_VERSION: ${{ github.event_name == 'pull_request' && format('dev-{0}', github.event.pull_request.head.ref) || 'dev-main' }} + steps: + - uses: actions/checkout@v6 + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - name: Setup PHP and install dependencies + uses: ./.fast-forward-actions/.github/actions/php/setup-composer + with: + php-version: ${{ matrix.php-version }} + extensions: pcov, pcntl + coverage: pcov + root-version: ${{ env.TESTS_ROOT_VERSION }} + install-options: --prefer-dist --no-progress --no-interaction --no-plugins --no-scripts + + - name: Setup Fast Forward DevTools + uses: ./.fast-forward-actions/.github/actions/dev-tools/setup + with: + version: ${{ inputs.dev-tools-version || '^1.0' }} + + - name: Composer Audit + env: + COMPOSER_ROOT_VERSION: ${{ env.TESTS_ROOT_VERSION }} + run: composer audit + + - name: Resolve minimum coverage + id: minimum-coverage + run: echo "value=${INPUT_MIN_COVERAGE:-80}" >> "$GITHUB_OUTPUT" + env: + INPUT_MIN_COVERAGE: ${{ inputs.min-coverage }} + + - name: Run PHPUnit tests + env: + COMPOSER_ROOT_VERSION: ${{ env.TESTS_ROOT_VERSION }} + run: | + "${DEV_TOOLS_BIN}" tests --coverage=.dev-tools/coverage --min-coverage=${{ steps.minimum-coverage.outputs.value }} + + - name: Publish required test status + if: ${{ always() && inputs.publish-required-statuses }} + env: + GH_TOKEN: ${{ github.token }} + TARGET_SHA: ${{ github.sha }} + TARGET_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + TEST_CONTEXT: Run Tests (${{ matrix.php-version }}) + TEST_RESULT: ${{ job.status }} + run: | + if [ "${TEST_RESULT}" = "success" ]; then + state="success" + description="Workflow-dispatched PHPUnit job passed." + else + state="failure" + description="Workflow-dispatched PHPUnit job result: ${TEST_RESULT}." + fi + + gh api \ + --method POST \ + "repos/${GITHUB_REPOSITORY}/statuses/${TARGET_SHA}" \ + -f state="${state}" \ + -f context="${TEST_CONTEXT}" \ + -f description="${description}" \ + -f target_url="${TARGET_URL}" + + dependency-health: + needs: resolve_php + if: needs.resolve_php.outputs.repository-is-testable == 'true' + name: Dependency Health + runs-on: ubuntu-latest + env: + TESTS_ROOT_VERSION: ${{ github.event_name == 'pull_request' && format('dev-{0}', github.event.pull_request.head.ref) || 'dev-main' }} + steps: + - uses: actions/checkout@v6 + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - name: Setup PHP and install dependencies + uses: ./.fast-forward-actions/.github/actions/php/setup-composer + with: + php-version: ${{ needs.resolve_php.outputs.php-version }} + root-version: ${{ env.TESTS_ROOT_VERSION }} + install-options: --prefer-dist --no-progress --no-interaction --no-plugins --no-scripts + + - name: Setup Fast Forward DevTools + uses: ./.fast-forward-actions/.github/actions/dev-tools/setup + with: + version: ${{ inputs.dev-tools-version || '^1.0' }} + + - name: Run dependency health check + env: + COMPOSER_ROOT_VERSION: ${{ env.TESTS_ROOT_VERSION }} + run: | + "${DEV_TOOLS_BIN}" dependencies --max-outdated=${{ inputs.max-outdated || -1 }} + + summarize: + if: ${{ always() }} + name: Summarize Test Workflow + needs: + - resolve_php + - tests + - dependency-health + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - uses: ./.fast-forward-actions/.github/actions/summary/write + with: + markdown: | + ## Tests Workflow Summary + + - Workflow PHP version: `${{ needs.resolve_php.outputs.php-version }}` + - PHP version source: `${{ needs.resolve_php.outputs.php-version-source }}` + - Composer project detected: `${{ needs.resolve_php.outputs.repository-has-composer-json }}` + - PHP files detected: `${{ needs.resolve_php.outputs.repository-has-php-files }}` + - PHPUnit configuration detected: `${{ needs.resolve_php.outputs.repository-has-phpunit-config }}` + - PHPUnit test files detected: `${{ needs.resolve_php.outputs.repository-has-test-files }}` + - PHPUnit execution required: `${{ needs.resolve_php.outputs.repository-is-testable }}` + - Test matrix: `${{ needs.resolve_php.outputs.test-matrix }}` + - Minimum coverage threshold: `${{ inputs.min-coverage || 80 }}` + - Dependency health `max-outdated`: `${{ inputs.max-outdated || -1 }}` + - Tests job result: `${{ needs.tests.result }}` + - Dependency health result: `${{ needs.dependency-health.result }}` + + publish_required_statuses: + if: ${{ always() && inputs.publish-required-statuses && needs.resolve_php.outputs.repository-is-testable == 'true' }} + name: Publish Required Test Statuses + needs: + - resolve_php + runs-on: ubuntu-latest + steps: + - name: Publish pending required test statuses + env: + GH_TOKEN: ${{ github.token }} + TARGET_SHA: ${{ github.sha }} + TARGET_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + TEST_MATRIX: ${{ needs.resolve_php.outputs.test-matrix }} + run: | + php -r "foreach (json_decode(getenv('TEST_MATRIX'), true, 512, JSON_THROW_ON_ERROR)['php-version'] as \$version) { echo \$version, PHP_EOL; }" | while IFS= read -r php_version; do + gh api \ + --method POST \ + "repos/${GITHUB_REPOSITORY}/statuses/${TARGET_SHA}" \ + -f state="pending" \ + -f context="Run Tests (${php_version})" \ + -f description="Workflow-dispatched PHPUnit job is pending." \ + -f target_url="${TARGET_URL}" + done diff --git a/.github/workflows/wiki-maintenance.yml b/.github/workflows/wiki-maintenance.yml new file mode 100644 index 0000000..58597d7 --- /dev/null +++ b/.github/workflows/wiki-maintenance.yml @@ -0,0 +1,182 @@ +name: Maintain Wiki Publication + +on: + workflow_call: +permissions: + contents: read + +env: + FORCE_COLOR: '1' + FAST_FORWARD_ACTIONS_REPOSITORY: php-fast-forward/.github + FAST_FORWARD_ACTIONS_REF: ${{ github.repository != 'php-fast-forward/.github' && vars.FAST_FORWARD_ACTIONS_REF || '' }} + +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' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + + env: + WIKI_PUBLISH_BRANCH: master + 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 workspace as safe for git + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + git config --global --add safe.directory "$GITHUB_WORKSPACE/.github/wiki" + + - &resolve_shared_action_ref + name: Resolve shared action ref + id: shared_actions + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CURRENT_REF: ${{ github.head_ref || github.ref_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + + if [ -n "${FAST_FORWARD_ACTIONS_REF}" ]; then + ref="${FAST_FORWARD_ACTIONS_REF}" + elif [ "${GITHUB_REPOSITORY}" = "${FAST_FORWARD_ACTIONS_REPOSITORY}" ]; then + if [ "${GITHUB_EVENT_NAME}" = "pull_request_target" ] && [ -n "${BASE_SHA:-}" ]; then + ref="${BASE_SHA}" + else + ref="${CURRENT_REF:-main}" + fi + else + ref="$(gh api "repos/${FAST_FORWARD_ACTIONS_REPOSITORY}/releases/latest" --jq .tag_name 2>/dev/null || true)" + + if [ -z "${ref}" ] || [ "${ref}" = "null" ]; then + ref="main" + fi + fi + + echo "ref=${ref}" >> "${GITHUB_OUTPUT}" + + - &checkout_shared_action_source + name: Checkout shared action source + uses: actions/checkout@v6 + with: + repository: ${{ env.FAST_FORWARD_ACTIONS_REPOSITORY }} + ref: ${{ steps.shared_actions.outputs.ref }} + path: .fast-forward-actions + sparse-checkout: | + .github/actions + + - name: Prepare wiki publish branch from preview branch + id: prepare_publish + uses: ./.fast-forward-actions/.github/actions/wiki/prepare-publish-branch + with: + publish-branch: ${{ env.WIKI_PUBLISH_BRANCH }} + preview-branch: ${{ env.WIKI_PREVIEW_BRANCH }} + + - name: Push wiki publish branch + working-directory: .github/wiki + run: git push --force-with-lease origin HEAD:"${WIKI_PUBLISH_BRANCH}" + + - name: Validate wiki publish branch + uses: ./.fast-forward-actions/.github/actions/wiki/validate-publish-branch + with: + publish-branch: ${{ env.WIKI_PUBLISH_BRANCH }} + preview-branch: ${{ env.WIKI_PREVIEW_BRANCH }} + expected-preview-sha: ${{ steps.prepare_publish.outputs.expected-preview-sha }} + + - name: Delete wiki preview branch + uses: ./.fast-forward-actions/.github/actions/wiki/delete-preview-branch + with: + preview-branch: ${{ env.WIKI_PREVIEW_BRANCH }} + + - uses: ./.fast-forward-actions/.github/actions/summary/write + with: + markdown: | + ## Wiki Publish Summary + + - Publish branch: `${{ env.WIKI_PUBLISH_BRANCH }}` + - Preview branch: `${{ env.WIKI_PREVIEW_BRANCH }}` + - Expected preview SHA: `${{ steps.prepare_publish.outputs.expected-preview-sha }}` + - Publish validation: completed + - Preview cleanup: `${{ env.WIKI_PREVIEW_BRANCH }}` deleted + + cleanup_closed_preview: + name: Delete Closed PR Wiki Preview + if: github.event_name == 'pull_request_target' && github.event.pull_request.merged == false + 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" + + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - name: Delete wiki preview branch + uses: ./.fast-forward-actions/.github/actions/wiki/delete-preview-branch + with: + preview-branch: ${{ env.WIKI_PREVIEW_BRANCH }} + + - uses: ./.fast-forward-actions/.github/actions/summary/write + with: + markdown: | + ## Wiki Preview Cleanup Summary + + - Deleted preview branch: `${{ env.WIKI_PREVIEW_BRANCH }}` + - Trigger: closed pull request without merge + + cleanup_orphaned_previews: + name: Delete Orphaned Wiki Previews + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + + 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" + + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - name: Delete wiki branches for closed pull requests + id: cleanup + uses: ./.fast-forward-actions/.github/actions/wiki/cleanup-orphaned-previews + + - uses: ./.fast-forward-actions/.github/actions/summary/write + with: + markdown: | + ## Wiki Orphan Cleanup Summary + + - Deleted preview branches: `${{ steps.cleanup.outputs.deleted || '0' }}` + - Retained preview branches: `${{ steps.cleanup.outputs.skipped || '0' }}` + - Unresolved preview branches: `${{ steps.cleanup.outputs.unresolved || '0' }}` diff --git a/.github/workflows/wiki-preview.yml b/.github/workflows/wiki-preview.yml new file mode 100644 index 0000000..d746a28 --- /dev/null +++ b/.github/workflows/wiki-preview.yml @@ -0,0 +1,166 @@ +name: Update Wiki Preview + +on: + workflow_call: + inputs: + dev-tools-version: + description: Composer version constraint or branch used when globally installing fast-forward/dev-tools. + required: false + type: string + default: ^1.0 + +permissions: + contents: read + +env: + FORCE_COLOR: '1' + FAST_FORWARD_ACTIONS_REPOSITORY: php-fast-forward/.github + FAST_FORWARD_ACTIONS_REF: ${{ github.repository != 'php-fast-forward/.github' && vars.FAST_FORWARD_ACTIONS_REF || '' }} + +jobs: + resolve_php: + name: Resolve PHP Version + runs-on: ubuntu-latest + outputs: + php-version: ${{ steps.resolve.outputs.php-version }} + php-version-source: ${{ steps.resolve.outputs.php-version-source }} + + steps: + - uses: actions/checkout@v6 + - &resolve_shared_action_ref + name: Resolve shared action ref + id: shared_actions + shell: bash + env: + GH_TOKEN: ${{ github.token }} + CURRENT_REF: ${{ github.head_ref || github.ref_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + + if [ -n "${FAST_FORWARD_ACTIONS_REF}" ]; then + ref="${FAST_FORWARD_ACTIONS_REF}" + elif [ "${GITHUB_REPOSITORY}" = "${FAST_FORWARD_ACTIONS_REPOSITORY}" ]; then + if [ "${GITHUB_EVENT_NAME}" = "pull_request_target" ] && [ -n "${BASE_SHA:-}" ]; then + ref="${BASE_SHA}" + else + ref="${CURRENT_REF:-main}" + fi + else + ref="$(gh api "repos/${FAST_FORWARD_ACTIONS_REPOSITORY}/releases/latest" --jq .tag_name 2>/dev/null || true)" + + if [ -z "${ref}" ] || [ "${ref}" = "null" ]; then + ref="main" + fi + fi + + echo "ref=${ref}" >> "${GITHUB_OUTPUT}" + + - &checkout_shared_action_source + name: Checkout shared action source + uses: actions/checkout@v6 + with: + repository: ${{ env.FAST_FORWARD_ACTIONS_REPOSITORY }} + ref: ${{ steps.shared_actions.outputs.ref }} + path: .fast-forward-actions + sparse-checkout: | + .github/actions + + - name: Resolve workflow PHP version + id: resolve + uses: ./.fast-forward-actions/.github/actions/php/resolve-version + + preview: + needs: resolve_php + name: Update Wiki Preview + runs-on: ubuntu-latest + permissions: + actions: write + contents: write + pull-requests: read + + env: + WIKI_PREVIEW_BRANCH: pr-${{ github.event.pull_request.number }} + + steps: + - name: Checkout PR branch + uses: actions/checkout@v6 + with: + token: ${{ github.token }} + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + submodules: recursive + fetch-depth: 0 + - *resolve_shared_action_ref + - *checkout_shared_action_source + + - name: Setup PHP and install dependencies + uses: ./.fast-forward-actions/.github/actions/php/setup-composer + with: + php-version: ${{ needs.resolve_php.outputs.php-version }} + root-version: dev-${{ github.event.pull_request.head.ref }} + safe-directories: | + ${{ github.workspace }}/.github/wiki + + - name: Setup Fast Forward DevTools + uses: ./.fast-forward-actions/.github/actions/dev-tools/setup + with: + version: ${{ inputs.dev-tools-version || '^1.0' }} + + - name: Prepare wiki preview branch + uses: ./.fast-forward-actions/.github/actions/wiki/prepare-preview-branch + with: + preview-branch: ${{ env.WIKI_PREVIEW_BRANCH }} + + - name: Create Docs Markdown + env: + COMPOSER_ROOT_VERSION: dev-${{ github.event.pull_request.head.ref }} + run: | + "${DEV_TOOLS_BIN}" wiki --target=.github/wiki + + - name: Commit & push wiki preview branch + id: wiki_commit + uses: EndBug/add-and-commit@v10 + with: + cwd: .github/wiki + add: . + message: "Update wiki docs for PR #${{ github.event.pull_request.number }}" + default_author: github_actions + push: origin HEAD:${{ env.WIKI_PREVIEW_BRANCH }} + + - name: Check submodule pointer changes + id: submodule_status + run: | + if git diff --quiet -- .github/wiki; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Commit parent repo submodule pointer + if: steps.submodule_status.outputs.changed == 'true' + id: parent_commit + uses: EndBug/add-and-commit@v10 + with: + add: .github/wiki + message: "Update wiki submodule pointer for PR #${{ github.event.pull_request.number }}" + default_author: github_actions + pull: "--rebase --autostash" + push: true + + - name: Dispatch tests for wiki pointer commit + if: steps.submodule_status.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: gh workflow run tests.yml --ref "${HEAD_REF}" -f publish-required-statuses=true + + - uses: ./.fast-forward-actions/.github/actions/summary/write + with: + markdown: | + ## Wiki Preview Summary + + - Preview branch: `${{ env.WIKI_PREVIEW_BRANCH }}` + - Submodule pointer changed: `${{ steps.submodule_status.outputs.changed }}` + - Parent repository pointer commit result: `${{ steps.submodule_status.outputs.changed == 'true' && 'updated' || 'unchanged' }}` + - Tests dispatch result: `${{ steps.submodule_status.outputs.changed == 'true' && 'requested' || 'not needed' }}` diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7128691 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Extract shared GitHub workflows and actions into the organization automation repository (#3) + +### Changed + +- Replace consumer-facing `automation-ref` plumbing with shared-action source checkout resolved inside reusable workflows (#3) diff --git a/README.md b/README.md index b9673b7..1c620bd 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ This repository holds the public GitHub organization profile and shared GitHub metadata for [`php-fast-forward`](https://github.com/php-fast-forward). It exists so the organization page can have a proper manifesto, visual identity, -and sponsorship metadata without mixing that content into the framework or -tooling repositories themselves. +shared automation, and sponsorship metadata without mixing that content into the +framework or tooling repositories themselves. ## What lives here @@ -19,6 +19,14 @@ tooling repositories themselves. profile - [`.github/FUNDING.yml`](./.github/FUNDING.yml) defines the organization sponsorship links +- [`.github/workflows`](./.github/workflows) contains reusable GitHub Actions + workflows shared by Fast Forward repositories +- [`.github/actions`](./.github/actions) contains composite actions used by the + shared workflows +- [`composer.json`](./composer.json) installs local development tooling so this + repository can run the same shared workflows it publishes +- [`docs/github-actions-inventory.md`](./docs/github-actions-inventory.md) + records which workflow and action surfaces moved here first ## Notes @@ -27,3 +35,47 @@ tooling repositories themselves. - Framework code and developer tooling live in their own repositories, especially [`framework`](https://github.com/php-fast-forward/framework) and [`dev-tools`](https://github.com/php-fast-forward/dev-tools) + +## Shared automation + +The organization automation split follows +[`php-fast-forward/dev-tools#240`](https://github.com/php-fast-forward/dev-tools/issues/240): + +- this repository owns reusable workflows and composite GitHub Actions +- `dev-tools` owns the PHP CLI commands, Composer plugin, and consumer workflow + wrapper synchronization +- consumer repositories keep thin workflow files that call reusable workflows + from this repository + +Reusable workflows checkout this repository's `.github/actions` tree into +`.fast-forward-actions` before calling local composite-action paths. The +checkout is explicit about `repository: php-fast-forward/.github`, because a +plain `actions/checkout` inside a reusable workflow checks out the consumer +repository. Consumer wrappers SHOULD call reusable workflows by tag, for example +`php-fast-forward/.github/.github/workflows/tests.yml@v0.1.0`, so Dependabot can +propose shared automation updates. + +The shared-action source ref is resolved inside the reusable workflow. Local +runs in this repository use the current ref, with `pull_request_target` pinned +to the base SHA. Consumer `workflow_call` runs use the latest stable `.github` +release and fall back to `main` when no release exists yet. A consumer can set a +repository variable named `FAST_FORWARD_ACTIONS_REF` to temporarily smoke-test a +specific shared-action branch without adding another workflow input. + +Workflows that need the Fast Forward CLI use `.github/actions/dev-tools/setup`, +which prefers an existing project-local `vendor/bin/dev-tools` binary and +otherwise installs `fast-forward/dev-tools` globally through Composer. The setup +action accepts a `version` input so wrapper workflows can test a specific +`dev-tools` branch or version without requiring the consumer package to depend +on `fast-forward/dev-tools`. + +The shared workflows keep their local repository triggers here as a smoke test +for the reusable implementation. PHP testing and report jobs detect whether the +checked-out repository has Composer metadata, PHPUnit configuration, and test +files before running, so documentation-only or automation-only repositories can +consume the workflows without producing false failures. + +The first extraction keeps wrappers in `dev-tools` until the coordinated +consumer-facing wrapper update is ready. Release-safe references for those +wrappers remain tracked separately in +[`php-fast-forward/dev-tools#238`](https://github.com/php-fast-forward/dev-tools/issues/238). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d949c7f --- /dev/null +++ b/composer.json @@ -0,0 +1,69 @@ +{ + "name": "fast-forward/github-automation", + "description": "Organization profile and shared GitHub automation for PHP Fast Forward repositories.", + "license": "MIT", + "type": "project", + "keywords": [ + "automation", + "fast-forward", + "github", + "github-actions", + "workflows" + ], + "readme": "README.md", + "authors": [ + { + "name": "Felipe Sayao Lobato Abreu", + "email": "github@mentordosnerds.com", + "homepage": "https://github.com/coisa", + "role": "Maintainer" + } + ], + "homepage": "https://github.com/php-fast-forward/.github", + "support": { + "issues": "https://github.com/php-fast-forward/.github/issues", + "source": "https://github.com/php-fast-forward/.github" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/php-fast-forward" + }, + { + "type": "custom", + "url": "https://www.paypal.com/donate/?business=JLDAF45XZ8D84" + } + ], + "require": { + "php": "^8.3" + }, + "require-dev": { + "fast-forward/dev-tools": "^1.0" + }, + "minimum-stability": "stable", + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "fast-forward/dev-tools": true, + "phpdocumentor/shim": true, + "phpro/grumphp-shim": true, + "pyrech/composer-changelogs": true + }, + "platform": { + "php": "8.3.0" + }, + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + }, + "grumphp": { + "config-default-path": "vendor/fast-forward/dev-tools/grumphp.yml" + } + }, + "scripts": { + "dev-tools": "dev-tools", + "dev-tools:fix": "@dev-tools --fix" + } +} diff --git a/docs/github-actions-inventory.md b/docs/github-actions-inventory.md new file mode 100644 index 0000000..1e9ee4f --- /dev/null +++ b/docs/github-actions-inventory.md @@ -0,0 +1,70 @@ +# GitHub Actions Inventory + +This inventory records the first shared automation extraction from +`php-fast-forward/dev-tools` into the organization `.github` repository. + +## Shared reusable workflows + +The following reusable workflows are copied into `.github/workflows/` because +they are called by consumer workflow wrappers synchronized by `dev-tools`: + +- `auto-assign.yml` +- `auto-resolve-conflicts.yml` +- `changelog.yml` +- `label-sync.yml` +- `reports.yml` +- `review.yml` +- `tests.yml` +- `wiki-maintenance.yml` +- `wiki-preview.yml` + +## Composite actions + +The full `.github/actions/` tree from `dev-tools` is copied into this +repository because the reusable workflows depend on actions across these areas: + +- changelog release automation +- GitHub Pages report previews +- predictable conflict resolution +- pull request label synchronization +- PHP and Composer setup +- project board synchronization +- review request rendering +- step summary rendering +- wiki preview and publication helpers + +Reusable workflows reference these composite actions through local +`.fast-forward-actions/.github/actions/...` paths after explicitly checking out +`php-fast-forward/.github` into `.fast-forward-actions`. Consumer wrappers only +choose the reusable workflow ref; the called workflow owns its internal action +source checkout. + +This repository also adds `.github/actions/dev-tools/setup` so reusable +workflows can locate an existing project-local `dev-tools` binary or install +`fast-forward/dev-tools` globally when a consumer repository does not require +the package directly. + +The PHP action group also includes `.github/actions/php/detect-project`, used by +test and report workflows to skip PHPUnit/report generation when a repository +does not have the Composer, PHPUnit, documentation, or test files those jobs +need. + +The reusable workflows keep their original local triggers in this repository so +changes can be smoke-tested here before consumer wrappers are updated. + +Shared-action source checkout resolves to the current ref for local runs in this +repository, the latest stable `.github` release for consumer `workflow_call` +runs, and `main` when no release exists yet. Consumer repositories can set +`FAST_FORWARD_ACTIONS_REF` as a temporary smoke-test override. + +## Remaining in dev-tools + +The `dev-tools` repository remains responsible for: + +- the PHP CLI package and Composer plugin +- `dev-tools:sync` +- consumer workflow wrappers under `resources/github-actions/` +- future wrapper updates that point consumers at `php-fast-forward/.github` + +Repository-local workflows in `dev-tools` that are not consumer-facing reusable +workflow implementations are intentionally not part of this initial extraction.