From 00b320f9788dce9415c1fcd714f92ca9ff5b009b Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Fri, 19 Jun 2026 21:02:19 +0100 Subject: [PATCH] Synchronise template checks - Share issue and pull request template checks across active public template repositories. - Sync `check_template.rb` with the workflows so each target repository validates templates from trusted base-branch code. - Ignore template HTML comments when checking AI disclosure. - Remove stranded template-check files from unsupported target repositories during sync. - Limit syncing to `Homebrew/brew`, `Homebrew/homebrew-core` and `Homebrew/homebrew-cask`. --- .github/actions/sync/shared-config.rb | 31 ++++- .github/scripts/check_template.rb | 88 ++++++++++++++ .github/workflows/check-issues.yml | 162 ++++++++++++++++++++++++++ .github/workflows/check-prs.yml | 155 ++++++++++++++++++++++++ 4 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/check_template.rb create mode 100644 .github/workflows/check-issues.yml create mode 100644 .github/workflows/check-prs.yml diff --git a/.github/actions/sync/shared-config.rb b/.github/actions/sync/shared-config.rb index facfaf23..8e898db4 100755 --- a/.github/actions/sync/shared-config.rb +++ b/.github/actions/sync/shared-config.rb @@ -42,8 +42,15 @@ def git(*args) vale_ini = ".vale.ini" dependabot_template_yaml = ".github/actions/sync/dependabot.template.yml" dependabot_yaml = ".github/dependabot.yml" +check_template_rb = ".github/scripts/check_template.rb" docs_workflow_yaml = ".github/workflows/docs.yml" actionlint_workflow_yaml = ".github/workflows/actionlint.yml" +check_issues_workflow_yaml = ".github/workflows/check-issues.yml" +check_prs_workflow_yaml = ".github/workflows/check-prs.yml" +check_workflow_yamls = [ + check_issues_workflow_yaml, + check_prs_workflow_yaml, +].freeze stale_issues_workflow_yaml = ".github/workflows/stale-issues.yml" zizmor_yml = ".github/zizmor.yml" codeql_extensions_homebrew_actions_yml = ".github/codeql/extensions/homebrew-actions.yml" @@ -138,6 +145,11 @@ def git(*args) patchelf.rb ruby-macho ].freeze +template_check_repositories = %w[ + brew + homebrew-core + homebrew-cask +].freeze rejected_docs_basenames = %w[ _config.yml CNAME @@ -155,8 +167,10 @@ def git(*args) ruby_version, rubocop_yaml, dependabot_yaml, + check_template_rb, deprecated_lock_threads, actionlint_workflow_yaml, + *check_workflow_yamls, stale_issues_workflow_yaml, zizmor_yml, codeql_extensions_homebrew_actions_yml, @@ -249,7 +263,8 @@ def git(*args) "# This file is synced from `Homebrew/brew` by the `.github` repository, do not modify it directly.\n" \ "#{homebrew_rubocop_config}\n", ) - when dependabot_yaml, actionlint_workflow_yaml, stale_issues_workflow_yaml, + when dependabot_yaml, actionlint_workflow_yaml, check_issues_workflow_yaml, check_prs_workflow_yaml, + stale_issues_workflow_yaml, zizmor_yml, codeql_extensions_homebrew_actions_yml contents = if path == dependabot_yaml dependabot_config @@ -259,6 +274,11 @@ def git(*args) # ensure we don't replace the non-dependabot template files in this repository next if repository_name == ".github" + if check_workflow_yamls.include?(path) && template_check_repositories.none?(repository_name) + FileUtils.rm_f target_path + next + end + Pathname(path).read .chomp end @@ -268,6 +288,15 @@ def git(*args) "# This file is synced from the `.github` repository, do not modify it directly.\n" \ "#{contents}\n", ) + when check_template_rb + next if path == target_path.to_s + + if template_check_repositories.none?(repository_name) + FileUtils.rm_f target_path + next + end + + FileUtils.cp path, target_path when deprecated_lock_threads next unless target_path.exist? diff --git a/.github/scripts/check_template.rb b/.github/scripts/check_template.rb new file mode 100644 index 00000000..59e2122d --- /dev/null +++ b/.github/scripts/check_template.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "yaml" + +AI_MENTION = /\b(?:AI|LLM)\b/i +CHECKBOX_MARKER = /\A- \[[ xX]\] / +HTML_COMMENT_LINE = /\A\z/ +ISSUE_FORM_HEADING_MARKER = "### " +MARKDOWN_HEADING = /\A#+ / +MARKDOWN_HORIZONTAL_LINE = /\A-+\z/ +NORMALISED_CHECKBOX_MARKER = "- [ ] " +REQUIRED_TEMPLATE_PERCENTAGE = 75 +PERCENTAGE_SCALE = 100 + +lines = lambda do |path| + File.read(path, mode: "rb") + .encode("UTF-8", invalid: :replace, undef: :replace) + .lines(chomp: true) +end + +normalised_lines = lambda do |path| + lines.call(path).each_with_object([]) do |line, normalised_lines| + line = line.strip.sub(CHECKBOX_MARKER, NORMALISED_CHECKBOX_MARKER) + next if line.empty? + next if line.match?(MARKDOWN_HORIZONTAL_LINE) + next if line.match?(HTML_COMMENT_LINE) + + normalised_lines << line + end.uniq +end + +case ARGV.fetch(0) +when "pull-request" + # Pass when the body keeps at least REQUIRED_TEMPLATE_PERCENTAGE of the template's + # headings and checkboxes combined (ticked or not) and still discloses AI usage: + # either the template's AI disclosure checkbox (whose label mentions AI) or any + # mention of AI/LLM in the text. This blocks bodies that strip out the template + # (e.g. AI-generated pull requests) without caring whether boxes are ticked. + normalised_body = normalised_lines.call(ARGV.fetch(1)) + template_items = normalised_lines.call(ARGV.fetch(2)).select do |line| + line.start_with?(NORMALISED_CHECKBOX_MARKER) || line.match?(MARKDOWN_HEADING) + end + present_count = template_items.count { |item| normalised_body.include?(item) } + preserves_template = present_count * PERCENTAGE_SCALE >= template_items.count * REQUIRED_TEMPLATE_PERCENTAGE + discloses_ai = normalised_body.any? { |line| line.match?(AI_MENTION) } + + puts preserves_template && discloses_ai +when "issue" + # Pass when the body keeps at least REQUIRED_TEMPLATE_PERCENTAGE of some template's + # headings and checkboxes combined (ticked or not). Counting headings as well as + # checkboxes lets the feature template (a single checkbox) be told apart from a + # stripped body. The missing items from the closest template are reported on stderr. + body_lines = lines.call(ARGV.fetch(1)) + + templates = Dir.glob("#{ARGV.fetch(2)}/*.{yml,yaml}").filter_map do |template_path| + fields = YAML.safe_load_file(template_path).fetch("body", []) + headings = fields.filter_map { |field| field.dig("attributes", "label") if field["type"] != "markdown" } + checkboxes = fields.flat_map do |field| + next [] if field["type"] != "checkboxes" + + field.fetch("attributes", {}).fetch("options", []).map { |option| option.fetch("label") } + end + items = headings.map { |label| [:heading, label] } + checkboxes.map { |label| [:checkbox, label] } + next if items.empty? + + missing = items.reject { |_kind, label| body_lines.any? { |line| line.include?(label) } } + present_count = items.length - missing.length + { total: items.length, present_count: present_count, missing: missing } + end + + preserves_template = templates.any? do |template| + template[:present_count] * PERCENTAGE_SCALE >= template[:total] * REQUIRED_TEMPLATE_PERCENTAGE + end + + if preserves_template + puts true + else + puts false + closest_missing = templates.min_by { |template| template[:missing].length }&.fetch(:missing) + closest_missing&.each do |kind, label| + warn((kind == :checkbox) ? "- [ ] #{label}" : "- `#{ISSUE_FORM_HEADING_MARKER}#{label}` section") + end + end +else + warn "Usage: check_template.rb pull-request BODY TEMPLATE" + warn " check_template.rb issue BODY TEMPLATE_DIRECTORY" + exit 1 +end diff --git a/.github/workflows/check-issues.yml b/.github/workflows/check-issues.yml new file mode 100644 index 00000000..498b758e --- /dev/null +++ b/.github/workflows/check-issues.yml @@ -0,0 +1,162 @@ +name: Check issues + +on: + issues: + types: + - opened + - edited + - reopened + +permissions: {} + +defaults: + run: + shell: bash -euo pipefail {0} + +concurrency: + group: "check-issue-${{ github.event.issue.number }}" + cancel-in-progress: true + +jobs: + manage: + # Restrict this write-token workflow to repositories with supported templates. + # The first step also fails if a repository checkout has occurred. + if: >- + contains(fromJSON('["Homebrew/brew", "Homebrew/homebrew-core", "Homebrew/homebrew-cask"]'), github.repository) && + github.event.issue.user.login != 'BrewTestBot' && + github.event.issue.user.login != 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + # Read the trusted base-branch issue templates and checker through the API; + # write only the issue state needed here. + contents: read + issues: write + env: + GH_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + steps: + - name: Verify no checkout + run: | + if git -C "${GITHUB_WORKSPACE:?}" rev-parse --is-inside-work-tree &>/dev/null + then + echo "Refusing to run after a repository checkout in ${GITHUB_WORKSPACE}." >&2 + exit 1 + fi + + - name: Write issue body + env: + # Bind issue-controlled strings as environment variables instead of + # interpolating them into shell code. + ISSUE_BODY: ${{ github.event.issue.body }} + run: | + mkdir -p "${RUNNER_TEMP:?}/check-issues/templates" + printf "%s" "${ISSUE_BODY}" >"${RUNNER_TEMP}/check-issues/body" + + - name: Fetch issue templates + run: | + # Validate against the brew, homebrew-core and homebrew-cask templates so + # issues transferred between these repositories are not closed for using a + # sibling repository's (valid) template. + for repo in Homebrew/brew Homebrew/homebrew-core Homebrew/homebrew-cask + do + gh api "repos/${repo}/contents/.github/ISSUE_TEMPLATE?ref=main" \ + --jq '.[] | select(.type == "file" and (.name | test("\\.ya?ml$")) and .name != "config.yml") | .path' | + while IFS= read -r template_path + do + gh api "repos/${repo}/contents/${template_path}?ref=main" \ + --jq ".content" | + base64 --decode >"${RUNNER_TEMP}/check-issues/templates/${repo//\//-}-${template_path##*/}" + done + done + + - name: Fetch template checker + run: | + gh api "repos/${GITHUB_REPOSITORY:?}/contents/.github/scripts/check_template.rb?ref=main" \ + --jq ".content" | + base64 --decode >"${RUNNER_TEMP:?}/check_template.rb" + + - name: Check issue template + id: template + run: | + complete_template="$( + ruby "${RUNNER_TEMP:?}/check_template.rb" issue \ + "${RUNNER_TEMP}/check-issues/body" \ + "${RUNNER_TEMP}/check-issues/templates" \ + 2>"${RUNNER_TEMP}/check-issues/missing-checkboxes" + )" + case "${complete_template}" in + true | false) ;; + *) + echo "Unexpected template completion result: ${complete_template}" >&2 + exit 1 + ;; + esac + + echo "complete_template=${complete_template}" >>"${GITHUB_OUTPUT:?}" + + - name: Find incomplete template comment + id: comments + if: >- + (github.event.issue.state == 'closed' && + steps.template.outputs.complete_template == 'true') || + (github.event.issue.state != 'closed' && + steps.template.outputs.complete_template == 'false') + run: | + comment_ids="$( + gh api --paginate "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}/comments" \ + --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains(""))) | .id' + )" + if [[ -n "${comment_ids}" ]] + then + echo "has_incomplete_template_comment=true" >>"${GITHUB_OUTPUT:?}" + else + echo "has_incomplete_template_comment=false" >>"${GITHUB_OUTPUT:?}" + fi + + - name: Find issue closer + id: closer + if: >- + github.event.issue.state == 'closed' && + steps.template.outputs.complete_template == 'true' + run: | + closed_by="$(gh api "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}" --jq ".closed_by.login // \"\"")" + echo "closed_by=${closed_by}" >>"${GITHUB_OUTPUT:?}" + + - name: Reopen completed issue + if: >- + github.event.issue.state == 'closed' && + steps.template.outputs.complete_template == 'true' && + steps.closer.outputs.closed_by == 'github-actions[bot]' && + steps.comments.outputs.has_incomplete_template_comment == 'true' + run: | + gh api --method PATCH "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}" \ + -f state=open + + - name: Comment on incomplete issue + if: >- + github.event.issue.state != 'closed' && + steps.template.outputs.complete_template == 'false' && + steps.comments.outputs.has_incomplete_template_comment != 'true' + run: | + gh api --method POST "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}/comments" \ + --raw-field body="$( + cat < + Thanks for your issue. This has been closed because it appears to use an incomplete or outdated issue template. + + Please edit this issue to restore the following sections and checkboxes from the current issue template (you do not need to tick the checkboxes): + + $(cat "${RUNNER_TEMP:?}/check-issues/missing-checkboxes") + + This workflow will reopen this issue automatically once they are present. **Do not create a new issue for this.** + COMMENT + )" + + - name: Close incomplete issue + if: >- + github.event.issue.state != 'closed' && + steps.template.outputs.complete_template == 'false' + run: | + gh api --method PATCH "repos/${GITHUB_REPOSITORY:?}/issues/${ISSUE_NUMBER:?}" \ + -f state=closed \ + -f state_reason=not_planned diff --git a/.github/workflows/check-prs.yml b/.github/workflows/check-prs.yml new file mode 100644 index 00000000..8c73a4ad --- /dev/null +++ b/.github/workflows/check-prs.yml @@ -0,0 +1,155 @@ +name: Check pull requests + +on: + # `pull_request_target` has a write token, so this workflow must only ever run trusted + # base-branch code and must never checkout or execute pull request head code. + pull_request_target: + types: + - opened + - edited + - reopened + +permissions: {} + +defaults: + run: + shell: bash -euo pipefail {0} + +concurrency: + group: "check-pr-${{ github.event.pull_request.number }}" + cancel-in-progress: true + +jobs: + manage: + # Restrict this write-token workflow to repositories with supported templates. + # The first step also fails if a repository checkout has occurred. + if: >- + contains(fromJSON('["Homebrew/brew", "Homebrew/homebrew-core", "Homebrew/homebrew-cask"]'), github.repository) && + github.event.pull_request.user.login != 'BrewTestBot' && + github.event.pull_request.user.login != 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + # Read the trusted base-branch pull request template and checker through + # the API; write only the issue comment and pull request state needed here. + contents: read + issues: write + pull-requests: write + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TEMPLATE_URL: ${{ github.server_url }}/${{ github.repository }}/blob/main/.github/PULL_REQUEST_TEMPLATE.md + steps: + - name: Verify no checkout + run: | + if git -C "${GITHUB_WORKSPACE:?}" rev-parse --is-inside-work-tree &>/dev/null + then + echo "Refusing to run after a repository checkout in ${GITHUB_WORKSPACE}." >&2 + exit 1 + fi + + - name: Write pull request body + # Do not add a checkout here. `pull_request_target` has a write token, so this + # step must only use inline trusted code and API responses from `main`. + env: + # Bind PR-controlled strings as environment variables instead of interpolating + # them into shell code. + PR_BODY: ${{ github.event.pull_request.body }} + run: | + # This workflow intentionally uses `pull_request_target` so it can close and + # reopen forked pull requests. Keep this self-contained and never execute + # pull request code from this step. + + mkdir -p "${RUNNER_TEMP:?}/check-prs" + printf "%s" "${PR_BODY}" >"${RUNNER_TEMP}/check-prs/body" + + - name: Fetch pull request template + run: | + gh api "repos/${GITHUB_REPOSITORY:?}/contents/.github/PULL_REQUEST_TEMPLATE.md?ref=main" \ + --jq ".content" | + base64 --decode >"${RUNNER_TEMP:?}/check-prs/template" + + - name: Fetch template checker + run: | + gh api "repos/${GITHUB_REPOSITORY:?}/contents/.github/scripts/check_template.rb?ref=main" \ + --jq ".content" | + base64 --decode >"${RUNNER_TEMP:?}/check_template.rb" + + - name: Check pull request template + id: template + run: | + complete_template="$( + ruby "${RUNNER_TEMP:?}/check_template.rb" pull-request \ + "${RUNNER_TEMP}/check-prs/body" \ + "${RUNNER_TEMP}/check-prs/template" + )" + case "${complete_template}" in + true | false) ;; + *) + echo "Unexpected template completion result: ${complete_template}" >&2 + exit 1 + ;; + esac + + echo "complete_template=${complete_template}" >>"${GITHUB_OUTPUT:?}" + + - name: Find incomplete template comment + id: comments + if: >- + (github.event.pull_request.state == 'closed' && + steps.template.outputs.complete_template == 'true') || + (github.event.pull_request.state != 'closed' && + steps.template.outputs.complete_template == 'false') + run: | + comment_ids="$( + gh api --paginate "repos/${GITHUB_REPOSITORY:?}/issues/${PR_NUMBER:?}/comments" \ + --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains(""))) | .id' + )" + if [[ -n "${comment_ids}" ]] + then + echo "has_incomplete_template_comment=true" >>"${GITHUB_OUTPUT:?}" + else + echo "has_incomplete_template_comment=false" >>"${GITHUB_OUTPUT:?}" + fi + + - name: Find pull request closer + id: closer + if: >- + github.event.pull_request.state == 'closed' && + steps.template.outputs.complete_template == 'true' + run: | + closed_by="$(gh api "repos/${GITHUB_REPOSITORY:?}/issues/${PR_NUMBER:?}" --jq ".closed_by.login // \"\"")" + echo "closed_by=${closed_by}" >>"${GITHUB_OUTPUT:?}" + + - name: Reopen completed pull request + if: >- + github.event.pull_request.state == 'closed' && + steps.template.outputs.complete_template == 'true' && + steps.closer.outputs.closed_by == 'github-actions[bot]' && + steps.comments.outputs.has_incomplete_template_comment == 'true' + run: | + gh api --method PATCH "repos/${GITHUB_REPOSITORY:?}/pulls/${PR_NUMBER:?}" \ + -f state=open + + - name: Comment on incomplete pull request + if: >- + github.event.pull_request.state != 'closed' && + steps.template.outputs.complete_template == 'false' && + steps.comments.outputs.has_incomplete_template_comment != 'true' + run: | + gh api --method POST "repos/${GITHUB_REPOSITORY:?}/issues/${PR_NUMBER:?}/comments" \ + --raw-field body="$( + cat < + Thanks for your pull request. This has been closed because it appears to use an incomplete or outdated pull request template. + + Please edit this pull request to fill in the current [pull request template](${PR_TEMPLATE_URL:?}). This workflow will reopen this pull request automatically once the template is complete. **Do not open a new pull request for this.** + COMMENT + )" + + - name: Close incomplete pull request + if: >- + github.event.pull_request.state != 'closed' && + steps.template.outputs.complete_template == 'false' + run: | + gh api --method PATCH "repos/${GITHUB_REPOSITORY:?}/pulls/${PR_NUMBER:?}" \ + -f state=closed