From feebccbc65dfe2d093725f9678b5d2dc9be5500a Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Wed, 24 Jun 2026 16:15:19 +0300 Subject: [PATCH 1/5] Consolidate shared workflows into a single generated automation.yml entry point Replace the per-feature call-*.yml callers and the pull-request-target.yml dispatcher with one reusable automation.yml plus a single generated caller template, driven by automation-registry.yml as the source of truth. A pre-commit hook regenerates and fails on drift between the registry and the generated files. Closes learningequality/.github#86 Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/automation-caller.yml | 52 +++++ .github/workflows/automation.yml | 152 +++++++++++++++ .../call-contributor-issue-comment.yml | 12 -- .../workflows/call-contributor-pr-reply.yml | 12 -- .github/workflows/call-holiday-message.yml | 14 -- .github/workflows/call-issue-label.yml | 11 -- .github/workflows/call-issue-open.yml | 11 -- .../workflows/call-pull-request-target.yml | 11 -- .../workflows/call-update-pr-spreadsheet.yml | 14 -- .github/workflows/pre-commit.yml | 26 +++ .github/workflows/pull-request-target.yml | 32 --- .pre-commit-config.yaml | 10 +- automation-registry.yml | 158 +++++++++++++++ automation-template.yml | 52 +++++ docs/automation.md | 53 +++++ docs/community-automations.md | 2 +- package.json | 1 + scripts/generate-automation.js | 183 ++++++++++++++++++ 18 files changed, 687 insertions(+), 119 deletions(-) create mode 100644 .github/workflows/automation-caller.yml create mode 100644 .github/workflows/automation.yml delete mode 100644 .github/workflows/call-contributor-issue-comment.yml delete mode 100644 .github/workflows/call-contributor-pr-reply.yml delete mode 100644 .github/workflows/call-holiday-message.yml delete mode 100644 .github/workflows/call-issue-label.yml delete mode 100644 .github/workflows/call-issue-open.yml delete mode 100644 .github/workflows/call-pull-request-target.yml delete mode 100644 .github/workflows/call-update-pr-spreadsheet.yml create mode 100644 .github/workflows/pre-commit.yml delete mode 100644 .github/workflows/pull-request-target.yml create mode 100644 automation-registry.yml create mode 100644 automation-template.yml create mode 100644 docs/automation.md create mode 100644 scripts/generate-automation.js diff --git a/.github/workflows/automation-caller.yml b/.github/workflows/automation-caller.yml new file mode 100644 index 0000000..aa524e2 --- /dev/null +++ b/.github/workflows/automation-caller.yml @@ -0,0 +1,52 @@ +# This file is generated by scripts/generate-automation.js from automation-registry.yml. +# Do not edit it by hand - edit the registry and run: +# node scripts/generate-automation.js +# +# Caller template: copy this file to .github/workflows/automation.yml in a consumer repo. +# No edits are needed - the on: block is the exhaustive union of every enabled automation. + +name: Automation +on: + pull_request_target: + types: + - review_requested + - labeled + - opened + - assigned + - unassigned + - closed + - reopened + - edited + - review_request_removed + issue_comment: + types: + - created + issues: + types: + - opened + - reopened + - labeled + - unlabeled + - assigned + - unassigned + pull_request_review_comment: + types: + - created + schedule: + - cron: 1 0 * * 1 + workflow_dispatch: {} +permissions: + contents: write + pull-requests: write +jobs: + automation: + name: Run automations + uses: learningequality/.github/.github/workflows/automation.yml@main + secrets: + CONTRIBUTIONS_SHEET_NAME: ${{ secrets.CONTRIBUTIONS_SHEET_NAME }} + CONTRIBUTIONS_SPREADSHEET_ID: ${{ secrets.CONTRIBUTIONS_SPREADSHEET_ID }} + GH_UPLOADER_GCP_SA_CREDENTIALS: ${{ secrets.GH_UPLOADER_GCP_SA_CREDENTIALS }} + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml new file mode 100644 index 0000000..c3ad544 --- /dev/null +++ b/.github/workflows/automation.yml @@ -0,0 +1,152 @@ +# This file is generated by scripts/generate-automation.js from automation-registry.yml. +# Do not edit it by hand - edit the registry and run: +# node scripts/generate-automation.js +# +# Reusable workflow: one job per automation, gated on the original triggering event/action. + +name: Automation +on: + workflow_call: + secrets: + CONTRIBUTIONS_SHEET_NAME: + description: "Forwarded to: update-pr-spreadsheet" + required: false + CONTRIBUTIONS_SPREADSHEET_ID: + description: "Forwarded to: update-pr-spreadsheet" + required: false + GH_UPLOADER_GCP_SA_CREDENTIALS: + description: "Forwarded to: update-pr-spreadsheet" + required: false + LE_BOT_APP_ID: + description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, holiday-message, issue-open, issue-label-header, good-first-issue-comment, update-pr-spreadsheet, community-contribution-label, contributor-issue-comment, unassign-inactive-issues" + required: true + LE_BOT_PRIVATE_KEY: + description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, holiday-message, issue-open, issue-label-header, good-first-issue-comment, update-pr-spreadsheet, community-contribution-label, contributor-issue-comment, unassign-inactive-issues" + required: true + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: + description: "Forwarded to: contributor-pr-reply, holiday-message, contributor-issue-comment, unassign-inactive-issues" + required: false + SLACK_WEBHOOK_URL: + description: "Forwarded to: contributor-issue-comment" + required: false +jobs: + review-requested: + name: Review Requested + if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'review_requested' }} + uses: learningequality/.github/.github/workflows/review-requested.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + pull-request-label: + name: Pull Request Label + if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'labeled' }} + uses: learningequality/.github/.github/workflows/pull-request-label.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + dependabot-reviewer: + name: Dependabot Reviewer + if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'opened' && github.event.pull_request.user.login == 'dependabot[bot]' }} + uses: learningequality/.github/.github/workflows/dependabot-reviewer.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + contributor-pr-reply: + name: Contributor Pr Reply + if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'opened' }} + uses: learningequality/.github/.github/workflows/contributor-pr-reply.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} + holiday-message: + name: Holiday Message + if: ${{ (github.event_name == 'pull_request_target' && github.event.action == 'opened') || (github.event_name == 'issue_comment' && github.event.action == 'created') }} + uses: learningequality/.github/.github/workflows/holiday-message.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} + issue-open: + name: Issue Open + if: ${{ github.event_name == 'issues' && contains(fromJSON('["opened","reopened"]'), github.event.action) }} + uses: learningequality/.github/.github/workflows/manage-issue-header.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + issue-label-header: + name: Issue Label Header + if: ${{ github.event_name == 'issues' && contains(fromJSON('["labeled","unlabeled"]'), github.event.action) && github.event.label.name == 'help wanted' }} + uses: learningequality/.github/.github/workflows/manage-issue-header.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + good-first-issue-comment: + name: Good First Issue Comment + if: ${{ github.event_name == 'issues' && github.event.action == 'labeled' && (github.event.label.name == 'good first issue' || github.event.label.name == 'help wanted') }} + uses: learningequality/.github/.github/workflows/good-first-issue-comment.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + update-pr-spreadsheet: + name: Update Pr Spreadsheet + if: ${{ github.event_name == 'pull_request_target' && contains(fromJSON('["assigned","unassigned","opened","closed","reopened","edited","review_requested","review_request_removed"]'), github.event.action) }} + uses: learningequality/.github/.github/workflows/update-pr-spreadsheet.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + CONTRIBUTIONS_SPREADSHEET_ID: ${{ secrets.CONTRIBUTIONS_SPREADSHEET_ID }} + CONTRIBUTIONS_SHEET_NAME: ${{ secrets.CONTRIBUTIONS_SHEET_NAME }} + GH_UPLOADER_GCP_SA_CREDENTIALS: ${{ secrets.GH_UPLOADER_GCP_SA_CREDENTIALS }} + community-contribution-label: + name: Community Contribution Label + if: ${{ github.event_name == 'issues' && contains(fromJSON('["assigned","unassigned"]'), github.event.action) }} + uses: learningequality/.github/.github/workflows/community-contribution-label.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + contributor-issue-comment: + name: Contributor Issue Comment + if: ${{ github.event_name == 'issue_comment' && github.event.action == 'created' }} + uses: learningequality/.github/.github/workflows/contributor-issue-comment.yml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} + resolve-bot-pr-threads: + name: Resolve Bot Pr Threads + if: ${{ github.event_name == 'pull_request_review_comment' }} + uses: learningequality/.github/.github/workflows/resolve_bot_pr_threads.yml@main + unassign-inactive-issues: + name: Unassign Inactive Issues + if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + uses: learningequality/.github/.github/workflows/unassign-inactive-issues.yaml@main + permissions: + contents: read + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} diff --git a/.github/workflows/call-contributor-issue-comment.yml b/.github/workflows/call-contributor-issue-comment.yml deleted file mode 100644 index a9512b4..0000000 --- a/.github/workflows/call-contributor-issue-comment.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Handle contributor comment on GitHub issue -on: - issue_comment: - types: [created] -jobs: - call-workflow: - uses: learningequality/.github/.github/workflows/contributor-issue-comment.yml@main - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} diff --git a/.github/workflows/call-contributor-pr-reply.yml b/.github/workflows/call-contributor-pr-reply.yml deleted file mode 100644 index e8316e0..0000000 --- a/.github/workflows/call-contributor-pr-reply.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Send reply on a new contributor pull request -on: - pull_request_target: - types: [opened] -jobs: - call-workflow: - name: Call shared workflow - uses: learningequality/.github/.github/workflows/contributor-pr-reply.yml@main - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} diff --git a/.github/workflows/call-holiday-message.yml b/.github/workflows/call-holiday-message.yml deleted file mode 100644 index 6ed898b..0000000 --- a/.github/workflows/call-holiday-message.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Post holiday message on pull request or issue comment -on: - pull_request_target: - types: [opened] - issue_comment: - types: [created] -jobs: - call-workflow: - name: Call shared workflow - uses: learningequality/.github/.github/workflows/holiday-message.yml@main - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} diff --git a/.github/workflows/call-issue-label.yml b/.github/workflows/call-issue-label.yml deleted file mode 100644 index c7b5a50..0000000 --- a/.github/workflows/call-issue-label.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: Handle issue label events -on: - issues: - types: [labeled, unlabeled] -jobs: - call-workflow: - name: Call shared workflow - uses: learningequality/.github/.github/workflows/issue-label.yml@main - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/call-issue-open.yml b/.github/workflows/call-issue-open.yml deleted file mode 100644 index beae47b..0000000 --- a/.github/workflows/call-issue-open.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: Handle issue open events -on: - issues: - types: [opened, reopened] -jobs: - call-workflow: - name: Call shared workflow - uses: learningequality/.github/.github/workflows/issue-open.yml@main - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/call-pull-request-target.yml b/.github/workflows/call-pull-request-target.yml deleted file mode 100644 index e499bcd..0000000 --- a/.github/workflows/call-pull-request-target.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: Handle pull request events -on: - pull_request_target: - types: [opened, review_requested, labeled] -jobs: - call-workflow: - name: Call shared workflow - uses: learningequality/.github/.github/workflows/pull-request-target.yml@main - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/call-update-pr-spreadsheet.yml b/.github/workflows/call-update-pr-spreadsheet.yml deleted file mode 100644 index 837a34e..0000000 --- a/.github/workflows/call-update-pr-spreadsheet.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Update community pull requests spreadsheet -on: - pull_request_target: - types: [assigned, unassigned, opened, closed, reopened, edited, review_requested, review_request_removed] -jobs: - call-workflow: - name: Call shared workflow - uses: learningequality/.github/.github/workflows/update-pr-spreadsheet.yml@main - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - CONTRIBUTIONS_SPREADSHEET_ID: ${{ secrets.CONTRIBUTIONS_SPREADSHEET_ID }} - CONTRIBUTIONS_SHEET_NAME: ${{ secrets.CONTRIBUTIONS_SHEET_NAME }} - GH_UPLOADER_GCP_SA_CREDENTIALS: ${{ secrets.GH_UPLOADER_GCP_SA_CREDENTIALS }} diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..a8c59dc --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,26 @@ +name: Linting +on: + push: + branches: + - main + pull_request: + branches: + - main +permissions: + contents: read +jobs: + pre-commit: + name: Run prek + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Install prek + run: pipx install prek + - name: Run prek + run: prek run --all-files diff --git a/.github/workflows/pull-request-target.yml b/.github/workflows/pull-request-target.yml deleted file mode 100644 index f424a27..0000000 --- a/.github/workflows/pull-request-target.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Handle pull request events -on: - workflow_call: - secrets: - LE_BOT_APP_ID: - description: 'GitHub App ID for authentication' - required: true - LE_BOT_PRIVATE_KEY: - description: 'GitHub App Private Key for authentication' - required: true -jobs: - review-requested: - name: Handle review requested - if: ${{ github.event.action == 'review_requested' }} - uses: learningequality/.github/.github/workflows/review-requested.yml@main - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - pull-request-label: - name: Handle pull request label - if: ${{ github.event.action == 'labeled' }} - uses: learningequality/.github/.github/workflows/pull-request-label.yml@main - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - dependabot-reviewer: - name: Request review on dependabot PR - if: ${{ github.event.action == 'opened' && github.event.pull_request.user.login == 'dependabot[bot]' }} - uses: learningequality/.github/.github/workflows/dependabot-reviewer.yml@main - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 09b0ab0..aafaa97 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,10 +13,18 @@ repos: rev: v0.14.0 hooks: - id: yamlfmt - exclude: '^tests/cassettes' + exclude: '^tests/cassettes|^\.github/workflows/automation(-caller)?\.yml$|^automation-template\.yml$' - repo: https://github.com/rhysd/actionlint rev: v1.7.7 hooks: - id: actionlint # Expects shellcheck to be installed on the system # https://github.com/koalaman/shellcheck#installing + - repo: local + hooks: + - id: generate-automation + name: Check automation.yml / automation-template.yml are up to date + entry: node scripts/generate-automation.js --check + language: system + files: '^(automation-registry\.yml|\.github/workflows/automation\.yml|automation-template\.yml)$' + pass_filenames: false diff --git a/automation-registry.yml b/automation-registry.yml new file mode 100644 index 0000000..7029401 --- /dev/null +++ b/automation-registry.yml @@ -0,0 +1,158 @@ +# Single source of truth for the automations exposed through automation.yml. +# +# Each entry describes one automation: +# leaf - the reusable workflow file (in .github/workflows/) it dispatches to +# on - events/types (or schedule/workflow_dispatch) it should be triggered by; +# unioned across all enabled automations to build automation-template.yml's `on:` block +# if - the dispatch condition used as the job's `if:` in automation.yml +# secrets - secrets to declare on automation.yml and forward to the leaf; +# required: bot-app credentials must be present, optional: Slack/spreadsheet/GCP may be blank +# needs_write_permissions - true only for the one automation (resolve-bot-pr-threads) whose leaf job +# uses the default GITHUB_TOKEN with contents/pull-requests write instead of the bot-app token; +# every other job gets an explicit job-level `permissions: contents: read` override +# +# Run `node scripts/generate-automation.js` after editing this file to regenerate +# automation.yml and automation-template.yml. `node scripts/generate-automation.js --check` +# fails if the generated files are out of date (enforced by pre-commit + CI). + +automations: + - name: review-requested + enabled: true + leaf: review-requested.yml + on: + pull_request_target: + types: [review_requested] + if: "github.event_name == 'pull_request_target' && github.event.action == 'review_requested'" + secrets: + LE_BOT_APP_ID: required + LE_BOT_PRIVATE_KEY: required + - name: pull-request-label + enabled: true + leaf: pull-request-label.yml + on: + pull_request_target: + types: [labeled] + if: "github.event_name == 'pull_request_target' && github.event.action == 'labeled'" + secrets: + LE_BOT_APP_ID: required + LE_BOT_PRIVATE_KEY: required + - name: dependabot-reviewer + enabled: true + leaf: dependabot-reviewer.yml + on: + pull_request_target: + types: [opened] + if: "github.event_name == 'pull_request_target' && github.event.action == 'opened' && github.event.pull_request.user.login == 'dependabot[bot]'" + secrets: + LE_BOT_APP_ID: required + LE_BOT_PRIVATE_KEY: required + - name: contributor-pr-reply + enabled: true + leaf: contributor-pr-reply.yml + on: + pull_request_target: + types: [opened] + if: "github.event_name == 'pull_request_target' && github.event.action == 'opened'" + secrets: + LE_BOT_APP_ID: required + LE_BOT_PRIVATE_KEY: required + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: optional + - name: holiday-message + enabled: true + leaf: holiday-message.yml + on: + pull_request_target: + types: [opened] + issue_comment: + types: [created] + if: "(github.event_name == 'pull_request_target' && github.event.action == 'opened') || (github.event_name == 'issue_comment' && github.event.action == 'created')" + secrets: + LE_BOT_APP_ID: required + LE_BOT_PRIVATE_KEY: required + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: optional + - name: issue-open + enabled: true + leaf: manage-issue-header.yml + on: + issues: + types: [opened, reopened] + if: "github.event_name == 'issues' && contains(fromJSON('[\"opened\",\"reopened\"]'), github.event.action)" + secrets: + LE_BOT_APP_ID: required + LE_BOT_PRIVATE_KEY: required + - name: issue-label-header + enabled: true + leaf: manage-issue-header.yml + on: + issues: + types: [labeled, unlabeled] + if: "github.event_name == 'issues' && contains(fromJSON('[\"labeled\",\"unlabeled\"]'), github.event.action) && github.event.label.name == 'help wanted'" + secrets: + LE_BOT_APP_ID: required + LE_BOT_PRIVATE_KEY: required + - name: good-first-issue-comment + enabled: true + leaf: good-first-issue-comment.yml + on: + issues: + types: [labeled] + if: "github.event_name == 'issues' && github.event.action == 'labeled' && (github.event.label.name == 'good first issue' || github.event.label.name == 'help wanted')" + secrets: + LE_BOT_APP_ID: required + LE_BOT_PRIVATE_KEY: required + - name: update-pr-spreadsheet + enabled: true + leaf: update-pr-spreadsheet.yml + on: + pull_request_target: + types: [assigned, unassigned, opened, closed, reopened, edited, review_requested, review_request_removed] + if: "github.event_name == 'pull_request_target' && contains(fromJSON('[\"assigned\",\"unassigned\",\"opened\",\"closed\",\"reopened\",\"edited\",\"review_requested\",\"review_request_removed\"]'), github.event.action)" + secrets: + LE_BOT_APP_ID: required + LE_BOT_PRIVATE_KEY: required + CONTRIBUTIONS_SPREADSHEET_ID: optional + CONTRIBUTIONS_SHEET_NAME: optional + GH_UPLOADER_GCP_SA_CREDENTIALS: optional + - name: community-contribution-label + enabled: true + leaf: community-contribution-label.yml + on: + issues: + types: [assigned, unassigned] + if: "github.event_name == 'issues' && contains(fromJSON('[\"assigned\",\"unassigned\"]'), github.event.action)" + secrets: + LE_BOT_APP_ID: required + LE_BOT_PRIVATE_KEY: required + - name: contributor-issue-comment + enabled: true + leaf: contributor-issue-comment.yml + on: + issue_comment: + types: [created] + if: "github.event_name == 'issue_comment' && github.event.action == 'created'" + secrets: + LE_BOT_APP_ID: required + LE_BOT_PRIVATE_KEY: required + SLACK_WEBHOOK_URL: optional + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: optional + - name: resolve-bot-pr-threads + enabled: true + leaf: resolve_bot_pr_threads.yml + needs_write_permissions: true + on: + pull_request_review_comment: + types: [created] + if: "github.event_name == 'pull_request_review_comment'" + secrets: {} + - name: unassign-inactive-issues + enabled: true + leaf: unassign-inactive-issues.yaml + on: + schedule: + cron: "1 0 * * 1" + workflow_dispatch: {} + if: "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'" + secrets: + LE_BOT_APP_ID: required + LE_BOT_PRIVATE_KEY: required + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: optional diff --git a/automation-template.yml b/automation-template.yml new file mode 100644 index 0000000..aa524e2 --- /dev/null +++ b/automation-template.yml @@ -0,0 +1,52 @@ +# This file is generated by scripts/generate-automation.js from automation-registry.yml. +# Do not edit it by hand - edit the registry and run: +# node scripts/generate-automation.js +# +# Caller template: copy this file to .github/workflows/automation.yml in a consumer repo. +# No edits are needed - the on: block is the exhaustive union of every enabled automation. + +name: Automation +on: + pull_request_target: + types: + - review_requested + - labeled + - opened + - assigned + - unassigned + - closed + - reopened + - edited + - review_request_removed + issue_comment: + types: + - created + issues: + types: + - opened + - reopened + - labeled + - unlabeled + - assigned + - unassigned + pull_request_review_comment: + types: + - created + schedule: + - cron: 1 0 * * 1 + workflow_dispatch: {} +permissions: + contents: write + pull-requests: write +jobs: + automation: + name: Run automations + uses: learningequality/.github/.github/workflows/automation.yml@main + secrets: + CONTRIBUTIONS_SHEET_NAME: ${{ secrets.CONTRIBUTIONS_SHEET_NAME }} + CONTRIBUTIONS_SPREADSHEET_ID: ${{ secrets.CONTRIBUTIONS_SPREADSHEET_ID }} + GH_UPLOADER_GCP_SA_CREDENTIALS: ${{ secrets.GH_UPLOADER_GCP_SA_CREDENTIALS }} + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: ${{ secrets.SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/docs/automation.md b/docs/automation.md new file mode 100644 index 0000000..3f7b988 --- /dev/null +++ b/docs/automation.md @@ -0,0 +1,53 @@ +# Automation entry point + +Every learningequality repo that wants our shared bot automations (review-request routing, +contributor replies, issue header management, the community-contribution spreadsheet, etc.) +copies a single file: `automation-template.yml`. There is nothing else to maintain per repo — +no per-automation caller, no hand-written `on:` block to keep in sync. + +## How it fits together + +``` +consumer repo's .github/workflows/automation.yml (copy of automation-template.yml) + -> .github/workflows/automation.yml (this repo, reusable, one job per automation) + -> leaf workflow (e.g. review-requested.yml) + -> is-contributor.yml (where applicable) +``` + +That's 4 levels deep, GitHub's maximum for reusable workflow nesting - `automation.yml` calls +leaf workflows directly rather than going through an intermediate dispatcher. + +`automation-registry.yml` is the single source of truth: one entry per automation, declaring its +leaf workflow, the events/types (or schedule) that should trigger it, the `if:` condition used to +dispatch it, and which secrets it needs (required vs. optional). `scripts/generate-automation.js` +reads the registry and writes: + +- `.github/workflows/automation.yml` - the reusable workflow jobs +- `automation-template.yml` - the file every consumer repo copies, including the generated + exhaustive `on:` block (the union of every enabled automation's events/types) +- `.github/workflows/automation-caller.yml` - this repo dogfeeding the same template against itself + +A pre-commit hook (`generate-automation`, run via `prek`) regenerates and fails the commit if the +checked-in files don't match what the registry produces - so the generated files can never drift +from the registry. + +## Onboarding a new repo + +Copy `automation-template.yml` from this repo into the new repo as `.github/workflows/automation.yml`. +No edits required. Make sure the repo has access to the required secrets (`LE_BOT_APP_ID`, +`LE_BOT_PRIVATE_KEY`); the optional ones (Slack webhooks, the contributions spreadsheet, GCP +credentials) can be left unset - automations that don't need them still run. + +## Adding or toggling an automation + +1. Edit `automation-registry.yml`: add a new entry, or flip an existing one's `enabled: true/false` + (e.g. to switch `holiday-message` on/off seasonally - no per-repo changes needed, every consumer + picks it up the next time they pull `automation.yml@main`). +2. Run `node scripts/generate-automation.js` to regenerate `automation.yml`, `automation-template.yml`, + and `automation-caller.yml`. +3. Commit the registry change together with the regenerated files (pre-commit will refuse the + commit otherwise). +4. If the change adds a new event/type that no existing automation triggers on, every consumer repo + picks up the wider `on:` block the next time they re-copy `automation-template.yml` - existing + copies keep running on their current `on:` block until then, since GitHub workflow triggers are + evaluated from the file checked into the consumer repo itself, not from this repo. diff --git a/docs/community-automations.md b/docs/community-automations.md index 2036ffe..66c1556 100644 --- a/docs/community-automations.md +++ b/docs/community-automations.md @@ -73,4 +73,4 @@ Sends a holiday message to community pull requests and issue comments. In `scripts/contants.js` set: - `HOLIDAY_MESSAGE`: Message text -Before/after holidays, enable/disable all related workflows in all repositories that use it (search for `call-holiday-message`). +Before/after holidays, toggle `enabled:` for `holiday-message` in [`automation-registry.yml`](../automation-registry.yml) and regenerate (see [`docs/automation.md`](./automation.md)) - no per-repo changes needed. diff --git a/package.json b/package.json index 3926115..1681d88 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "axios": "^1.13.6", "dotenv": "^17.3.1", "googleapis": "^142.0.0", + "js-yaml": "^4.1.0", "node-fetch-native": "^1.6.7", "path": "^0.12.7" }, diff --git a/scripts/generate-automation.js b/scripts/generate-automation.js new file mode 100644 index 0000000..91e321a --- /dev/null +++ b/scripts/generate-automation.js @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/** + * Generates .github/workflows/automation.yml and automation-template.yml + * from automation-registry.yml. + * + * Usage: + * node scripts/generate-automation.js regenerate both files + * node scripts/generate-automation.js --check fail (exit 1) if regenerating + * would change either file + */ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); + +const ROOT = path.join(__dirname, '..'); +const REGISTRY_PATH = path.join(ROOT, 'automation-registry.yml'); +const AUTOMATION_PATH = path.join(ROOT, '.github', 'workflows', 'automation.yml'); +const TEMPLATE_PATH = path.join(ROOT, 'automation-template.yml'); +// This repo is consumer zero: it runs the same template against itself +// instead of a hand-copied caller, so it stays in sync automatically. +const DOGFOOD_PATH = path.join(ROOT, '.github', 'workflows', 'automation-caller.yml'); + +const GENERATED_HEADER = (description) => `# This file is generated by scripts/generate-automation.js from automation-registry.yml. +# Do not edit it by hand - edit the registry and run: +# node scripts/generate-automation.js +# +# ${description} + +`; + +const DUMP_OPTS = { indent: 2, lineWidth: -1, noRefs: true, quotingType: '"' }; + +// js-yaml quotes the top-level `on` key (YAML 1.1 treats on/off as booleans), +// but every hand-written workflow in this repo uses a bare `on:` - match that. +function unquoteOnKey(content) { + return content.replace(/^"on":/m, 'on:'); +} + +function loadRegistry() { + const raw = yaml.load(fs.readFileSync(REGISTRY_PATH, 'utf8')); + return raw.automations.filter((a) => a.enabled); +} + +function titleCase(name) { + return name + .split('-') + .map((w) => w[0].toUpperCase() + w.slice(1)) + .join(' '); +} + +// secretName -> { required: bool, usedBy: [automationName, ...] } +function collectSecrets(automations) { + const secrets = {}; + for (const a of automations) { + for (const [name, mode] of Object.entries(a.secrets || {})) { + if (!secrets[name]) secrets[name] = { required: false, usedBy: [] }; + if (mode === 'required') secrets[name].required = true; + secrets[name].usedBy.push(a.name); + } + } + return secrets; +} + +function buildAutomationYml(automations) { + const secrets = collectSecrets(automations); + const secretsYaml = {}; + for (const name of Object.keys(secrets).sort()) { + const info = secrets[name]; + secretsYaml[name] = { + description: `Forwarded to: ${info.usedBy.join(', ')}`, + required: info.required, + }; + } + + const jobs = {}; + for (const a of automations) { + const job = { + name: titleCase(a.name), + if: `\${{ ${a.if} }}`, + uses: `learningequality/.github/.github/workflows/${a.leaf}@main`, + }; + if (!a.needs_write_permissions) { + job.permissions = { contents: 'read' }; + } + const secretNames = Object.keys(a.secrets || {}); + if (secretNames.length) { + job.secrets = {}; + for (const s of secretNames) job.secrets[s] = `\${{ secrets.${s} }}`; + } + jobs[a.name] = job; + } + + const doc = { + name: 'Automation', + on: { workflow_call: { secrets: secretsYaml } }, + jobs, + }; + return unquoteOnKey( + GENERATED_HEADER( + 'Reusable workflow: one job per automation, gated on the original triggering event/action.' + ) + yaml.dump(doc, DUMP_OPTS) + ); +} + +function unionOn(automations) { + const on = {}; + for (const a of automations) { + for (const [event, value] of Object.entries(a.on || {})) { + if (event === 'schedule') { + on.schedule = on.schedule || []; + const cron = value.cron; + if (!on.schedule.some((s) => s.cron === cron)) on.schedule.push({ cron }); + } else if (value && Array.isArray(value.types)) { + on[event] = on[event] || { types: [] }; + for (const t of value.types) { + if (!on[event].types.includes(t)) on[event].types.push(t); + } + } else { + on[event] = on[event] || {}; + } + } + } + return on; +} + +function buildTemplateYml(automations) { + const secrets = collectSecrets(automations); + const secretsForward = {}; + for (const name of Object.keys(secrets).sort()) { + secretsForward[name] = `\${{ secrets.${name} }}`; + } + + const doc = { + name: 'Automation', + on: unionOn(automations), + permissions: { contents: 'write', 'pull-requests': 'write' }, + jobs: { + automation: { + name: 'Run automations', + uses: 'learningequality/.github/.github/workflows/automation.yml@main', + secrets: secretsForward, + }, + }, + }; + return unquoteOnKey( + GENERATED_HEADER( + 'Caller template: copy this file to .github/workflows/automation.yml in a consumer repo.\n' + + '# No edits are needed - the on: block is the exhaustive union of every enabled automation.' + ) + yaml.dump(doc, DUMP_OPTS) + ); +} + +function main() { + const check = process.argv.includes('--check'); + const automations = loadRegistry(); + + const templateContent = buildTemplateYml(automations); + const targets = [ + [AUTOMATION_PATH, buildAutomationYml(automations)], + [TEMPLATE_PATH, templateContent], + [DOGFOOD_PATH, templateContent], + ]; + + if (check) { + let drifted = false; + for (const [target, content] of targets) { + const current = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null; + if (current !== content) { + console.error(`Drift detected in ${path.relative(ROOT, target)} - run \`node scripts/generate-automation.js\``); + drifted = true; + } + } + process.exit(drifted ? 1 : 0); + } + + for (const [target, content] of targets) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + console.log(`Wrote ${path.relative(ROOT, target)}`); + } +} + +main(); From 5cac108a9ad99ac0cffe3cd36f3595013bac4e65 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 14 Jul 2026 14:05:04 +0300 Subject: [PATCH 2/5] Address self-review findings: secrets parity, drift-check coverage, schedule handling, permissions model - Mark all Slack/spreadsheet/GCP secrets as required in the registry to match leaf workflow declarations (restoring pre-PR behavior where callers forwarded all secrets unconditionally and leaves failed loudly if any were absent) - Add automation-caller.yml to the generate-automation pre-commit hook's files: pattern so direct edits to that generated file are caught by the drift check - Fix unionOn() to handle both object and list form for schedule entries, guarding against undefined cron if a second schedule is added in GHA-native format - Give automation-caller.yml its own generated header distinct from the consumer-facing one in automation-template.yml - Replace needs_write_permissions boolean with a generic permissions: map in the registry; generator now emits it directly rather than special-casing a flag - Compute collectSecrets() once in main() and pass to both build functions Regenerated automation.yml, automation-template.yml, and automation-caller.yml. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/automation-caller.yml | 3 +-- .github/workflows/automation.yml | 13 +++++---- .pre-commit-config.yaml | 2 +- automation-registry.yml | 27 ++++++++++--------- scripts/generate-automation.js | 35 +++++++++++-------------- 5 files changed, 40 insertions(+), 40 deletions(-) diff --git a/.github/workflows/automation-caller.yml b/.github/workflows/automation-caller.yml index aa524e2..09898b3 100644 --- a/.github/workflows/automation-caller.yml +++ b/.github/workflows/automation-caller.yml @@ -2,8 +2,7 @@ # Do not edit it by hand - edit the registry and run: # node scripts/generate-automation.js # -# Caller template: copy this file to .github/workflows/automation.yml in a consumer repo. -# No edits are needed - the on: block is the exhaustive union of every enabled automation. +# This repo's own automation caller - .github acts as consumer zero and stays in sync automatically. name: Automation on: diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index c3ad544..b00bd97 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -10,13 +10,13 @@ on: secrets: CONTRIBUTIONS_SHEET_NAME: description: "Forwarded to: update-pr-spreadsheet" - required: false + required: true CONTRIBUTIONS_SPREADSHEET_ID: description: "Forwarded to: update-pr-spreadsheet" - required: false + required: true GH_UPLOADER_GCP_SA_CREDENTIALS: description: "Forwarded to: update-pr-spreadsheet" - required: false + required: true LE_BOT_APP_ID: description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, holiday-message, issue-open, issue-label-header, good-first-issue-comment, update-pr-spreadsheet, community-contribution-label, contributor-issue-comment, unassign-inactive-issues" required: true @@ -25,10 +25,10 @@ on: required: true SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: description: "Forwarded to: contributor-pr-reply, holiday-message, contributor-issue-comment, unassign-inactive-issues" - required: false + required: true SLACK_WEBHOOK_URL: description: "Forwarded to: contributor-issue-comment" - required: false + required: true jobs: review-requested: name: Review Requested @@ -140,6 +140,9 @@ jobs: name: Resolve Bot Pr Threads if: ${{ github.event_name == 'pull_request_review_comment' }} uses: learningequality/.github/.github/workflows/resolve_bot_pr_threads.yml@main + permissions: + contents: write + pull-requests: write unassign-inactive-issues: name: Unassign Inactive Issues if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aafaa97..f7d12d7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,5 +26,5 @@ repos: name: Check automation.yml / automation-template.yml are up to date entry: node scripts/generate-automation.js --check language: system - files: '^(automation-registry\.yml|\.github/workflows/automation\.yml|automation-template\.yml)$' + files: '^(automation-registry\.yml|\.github/workflows/automation(-caller)?\.yml|automation-template\.yml)$' pass_filenames: false diff --git a/automation-registry.yml b/automation-registry.yml index 7029401..6c54dcf 100644 --- a/automation-registry.yml +++ b/automation-registry.yml @@ -6,10 +6,9 @@ # unioned across all enabled automations to build automation-template.yml's `on:` block # if - the dispatch condition used as the job's `if:` in automation.yml # secrets - secrets to declare on automation.yml and forward to the leaf; -# required: bot-app credentials must be present, optional: Slack/spreadsheet/GCP may be blank -# needs_write_permissions - true only for the one automation (resolve-bot-pr-threads) whose leaf job -# uses the default GITHUB_TOKEN with contents/pull-requests write instead of the bot-app token; -# every other job gets an explicit job-level `permissions: contents: read` override +# required/optional: whether the leaf workflow requires the secret to be set +# permissions - job-level permissions map; defaults to { contents: read } when absent; +# set explicitly for automations whose leaf uses the default GITHUB_TOKEN with write access # # Run `node scripts/generate-automation.js` after editing this file to regenerate # automation.yml and automation-template.yml. `node scripts/generate-automation.js --check` @@ -56,7 +55,7 @@ automations: secrets: LE_BOT_APP_ID: required LE_BOT_PRIVATE_KEY: required - SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: optional + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: required - name: holiday-message enabled: true leaf: holiday-message.yml @@ -69,7 +68,7 @@ automations: secrets: LE_BOT_APP_ID: required LE_BOT_PRIVATE_KEY: required - SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: optional + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: required - name: issue-open enabled: true leaf: manage-issue-header.yml @@ -110,9 +109,9 @@ automations: secrets: LE_BOT_APP_ID: required LE_BOT_PRIVATE_KEY: required - CONTRIBUTIONS_SPREADSHEET_ID: optional - CONTRIBUTIONS_SHEET_NAME: optional - GH_UPLOADER_GCP_SA_CREDENTIALS: optional + CONTRIBUTIONS_SPREADSHEET_ID: required + CONTRIBUTIONS_SHEET_NAME: required + GH_UPLOADER_GCP_SA_CREDENTIALS: required - name: community-contribution-label enabled: true leaf: community-contribution-label.yml @@ -133,12 +132,14 @@ automations: secrets: LE_BOT_APP_ID: required LE_BOT_PRIVATE_KEY: required - SLACK_WEBHOOK_URL: optional - SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: optional + SLACK_WEBHOOK_URL: required + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: required - name: resolve-bot-pr-threads enabled: true leaf: resolve_bot_pr_threads.yml - needs_write_permissions: true + permissions: + contents: write + pull-requests: write on: pull_request_review_comment: types: [created] @@ -155,4 +156,4 @@ automations: secrets: LE_BOT_APP_ID: required LE_BOT_PRIVATE_KEY: required - SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: optional + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: required diff --git a/scripts/generate-automation.js b/scripts/generate-automation.js index 91e321a..46e682c 100644 --- a/scripts/generate-automation.js +++ b/scripts/generate-automation.js @@ -61,8 +61,7 @@ function collectSecrets(automations) { return secrets; } -function buildAutomationYml(automations) { - const secrets = collectSecrets(automations); +function buildAutomationYml(automations, secrets) { const secretsYaml = {}; for (const name of Object.keys(secrets).sort()) { const info = secrets[name]; @@ -79,9 +78,7 @@ function buildAutomationYml(automations) { if: `\${{ ${a.if} }}`, uses: `learningequality/.github/.github/workflows/${a.leaf}@main`, }; - if (!a.needs_write_permissions) { - job.permissions = { contents: 'read' }; - } + job.permissions = a.permissions || { contents: 'read' }; const secretNames = Object.keys(a.secrets || {}); if (secretNames.length) { job.secrets = {}; @@ -108,8 +105,11 @@ function unionOn(automations) { for (const [event, value] of Object.entries(a.on || {})) { if (event === 'schedule') { on.schedule = on.schedule || []; - const cron = value.cron; - if (!on.schedule.some((s) => s.cron === cron)) on.schedule.push({ cron }); + const entries = Array.isArray(value) ? value : [value]; + for (const entry of entries) { + const { cron } = entry; + if (cron && !on.schedule.some((s) => s.cron === cron)) on.schedule.push({ cron }); + } } else if (value && Array.isArray(value.types)) { on[event] = on[event] || { types: [] }; for (const t of value.types) { @@ -123,8 +123,7 @@ function unionOn(automations) { return on; } -function buildTemplateYml(automations) { - const secrets = collectSecrets(automations); +function buildTemplateYml(automations, secrets, description) { const secretsForward = {}; for (const name of Object.keys(secrets).sort()) { secretsForward[name] = `\${{ secrets.${name} }}`; @@ -142,23 +141,21 @@ function buildTemplateYml(automations) { }, }, }; - return unquoteOnKey( - GENERATED_HEADER( - 'Caller template: copy this file to .github/workflows/automation.yml in a consumer repo.\n' + - '# No edits are needed - the on: block is the exhaustive union of every enabled automation.' - ) + yaml.dump(doc, DUMP_OPTS) - ); + return unquoteOnKey(GENERATED_HEADER(description) + yaml.dump(doc, DUMP_OPTS)); } function main() { const check = process.argv.includes('--check'); const automations = loadRegistry(); - const templateContent = buildTemplateYml(automations); + const secrets = collectSecrets(automations); const targets = [ - [AUTOMATION_PATH, buildAutomationYml(automations)], - [TEMPLATE_PATH, templateContent], - [DOGFOOD_PATH, templateContent], + [AUTOMATION_PATH, buildAutomationYml(automations, secrets)], + [TEMPLATE_PATH, buildTemplateYml(automations, secrets, + 'Caller template: copy this file to .github/workflows/automation.yml in a consumer repo.\n' + + '# No edits are needed - the on: block is the exhaustive union of every enabled automation.')], + [DOGFOOD_PATH, buildTemplateYml(automations, secrets, + 'This repo\'s own automation caller - .github acts as consumer zero and stays in sync automatically.')], ]; if (check) { From b8a5757879f29cb9719a84e09eb43db121861bfa Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 14 Jul 2026 14:07:46 +0300 Subject: [PATCH 3/5] Update docs: all secrets required, document permissions field Co-Authored-By: Claude Sonnet 4.6 --- docs/automation.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/automation.md b/docs/automation.md index 3f7b988..d37cb03 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -19,7 +19,7 @@ leaf workflows directly rather than going through an intermediate dispatcher. `automation-registry.yml` is the single source of truth: one entry per automation, declaring its leaf workflow, the events/types (or schedule) that should trigger it, the `if:` condition used to -dispatch it, and which secrets it needs (required vs. optional). `scripts/generate-automation.js` +dispatch it, which secrets it needs, and its job-level permissions (defaults to `contents: read`). `scripts/generate-automation.js` reads the registry and writes: - `.github/workflows/automation.yml` - the reusable workflow jobs @@ -34,9 +34,19 @@ from the registry. ## Onboarding a new repo Copy `automation-template.yml` from this repo into the new repo as `.github/workflows/automation.yml`. -No edits required. Make sure the repo has access to the required secrets (`LE_BOT_APP_ID`, -`LE_BOT_PRIVATE_KEY`); the optional ones (Slack webhooks, the contributions spreadsheet, GCP -credentials) can be left unset - automations that don't need them still run. +No edits required. Make sure the repo has all secrets configured: + +| Secret | Purpose | +|--------|---------| +| `LE_BOT_APP_ID` | GitHub App ID for bot authentication | +| `LE_BOT_PRIVATE_KEY` | GitHub App private key for bot authentication | +| `SLACK_WEBHOOK_URL` | Slack `#support-dev` channel webhook | +| `SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL` | Slack `#support-dev-notifications` channel webhook | +| `CONTRIBUTIONS_SPREADSHEET_ID` | Google Sheets spreadsheet ID for PR tracking | +| `CONTRIBUTIONS_SHEET_NAME` | Sheet name within the spreadsheet | +| `GH_UPLOADER_GCP_SA_CREDENTIALS` | GCP service account credentials for Sheets access | + +All secrets are required — leaf workflows fail loudly if any are absent. ## Adding or toggling an automation From 01729bf59e91a0237cfd12b3219136e02aab2bba Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 14 Jul 2026 14:34:40 +0300 Subject: [PATCH 4/5] Fix stale issue-label workflow reference in community-automations.md automation.yml now dispatches good-first-issue-comment directly rather than going through the issue-label intermediate workflow. Co-Authored-By: Claude Sonnet 4.6 --- docs/community-automations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/community-automations.md b/docs/community-automations.md index 66c1556..f18516f 100644 --- a/docs/community-automations.md +++ b/docs/community-automations.md @@ -50,7 +50,7 @@ In `scripts/constants.js` set: # `good-first-issue-comment` -Posts a guidance comment when the `good first issue` label is applied to an issue (triggered via the `issue-label` workflow). Explains the `/assign` command, issue limits, cooldown, and links to contributing guidelines. +Posts a guidance comment when the `good first issue` or `help wanted` label is applied to an issue. Explains the `/assign` command, issue limits, cooldown, and links to contributing guidelines. - Only posts if the issue also has `help wanted` label - Deletes any previous guidance comment from the bot before posting a new one From 6ab0ac8db480da7ec35ab7f142b6d33642af05dc Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 14 Jul 2026 14:40:28 +0300 Subject: [PATCH 5/5] Move js-yaml to devDependencies Only used by scripts/generate-automation.js at build time. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1681d88..668850d 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,13 @@ "axios": "^1.13.6", "dotenv": "^17.3.1", "googleapis": "^142.0.0", - "js-yaml": "^4.1.0", "node-fetch-native": "^1.6.7", "path": "^0.12.7" }, "devDependencies": { "eslint": "^8.57.0", "eslint-config-prettier": "^10.1.8", + "js-yaml": "^4.1.0", "prettier": "^3.8.1" } }