From feebccbc65dfe2d093725f9678b5d2dc9be5500a Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Wed, 24 Jun 2026 16:15:19 +0300 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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" } } From 0a2d04529252acfab8dff3c1e4cd7791010977d5 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 15 Sep 2026 15:34:01 +0300 Subject: [PATCH 06/10] Fix generator gaps and correct the secrets contract The caller template's permissions were hardcoded while the registry declared them per automation. A future entry needing a new scope would have made automation.yml request more than the caller grants, and the drift check could not catch it. The template now takes the union of every enabled automation's permissions. The regenerated template and caller came out unchanged, which confirms the union reproduces the old map. unionOn threw on undefined.includes or silently narrowed the trigger when one automation declared an event with types and another declared the same event without. A missing types list now means all types, whichever order the entries appear in. The five non-bot secrets are now optional, which matches the criteria in #86. Note that the leaf workflows still declare them required. Nothing breaks, because automation.yml always forwards the key. issue-open.yml and issue-label.yml lost their only callers when call-issue-open.yml and call-issue-label.yml were removed. No consumer repo references them, because consumers use call-manage-issue-header.yml, which points straight at the leaf. Also show PR instead of Pr in three job names, run the drift-check hook when only the generator changes, and fix the secrets paragraph in the docs, which promised a loud failure that does not happen. --- .github/workflows/automation.yml | 16 +++++++-------- .github/workflows/issue-label.yml | 27 ------------------------ .github/workflows/issue-open.yml | 17 ---------------- .pre-commit-config.yaml | 2 +- automation-registry.yml | 16 +++++++-------- docs/automation.md | 34 ++++++++++++++++++------------- scripts/generate-automation.js | 28 +++++++++++++++++++++---- 7 files changed, 61 insertions(+), 79 deletions(-) delete mode 100644 .github/workflows/issue-label.yml delete mode 100644 .github/workflows/issue-open.yml diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index b00bd97..7e58678 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: true + required: false CONTRIBUTIONS_SPREADSHEET_ID: description: "Forwarded to: update-pr-spreadsheet" - required: true + required: false GH_UPLOADER_GCP_SA_CREDENTIALS: description: "Forwarded to: update-pr-spreadsheet" - required: true + 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 @@ -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: true + required: false SLACK_WEBHOOK_URL: description: "Forwarded to: contributor-issue-comment" - required: true + required: false jobs: review-requested: name: Review Requested @@ -58,7 +58,7 @@ jobs: 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 + 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: @@ -105,7 +105,7 @@ jobs: 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 + 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: @@ -137,7 +137,7 @@ jobs: 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 + 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: diff --git a/.github/workflows/issue-label.yml b/.github/workflows/issue-label.yml deleted file mode 100644 index 2ea5039..0000000 --- a/.github/workflows/issue-label.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Handle issue label 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: - manage-issue-header: - name: Manage issue header - if: >- - (github.event.action == 'labeled' && github.event.label.name == 'help wanted') || (github.event.action == 'unlabeled' && github.event.label.name == 'help wanted') - uses: learningequality/.github/.github/workflows/manage-issue-header.yml@main - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} - good-first-issue-comment: - name: Post good first issue guidance comment - if: >- - 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 - secrets: - LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} - LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/issue-open.yml b/.github/workflows/issue-open.yml deleted file mode 100644 index ae1ea4f..0000000 --- a/.github/workflows/issue-open.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Handle issue open 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: - manage-issue-header: - name: Manage issue header - uses: learningequality/.github/.github/workflows/manage-issue-header.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 f7d12d7..4f22de7 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(-caller)?\.yml|automation-template\.yml)$' + files: '^(automation-registry\.yml|scripts/generate-automation\.js|\.github/workflows/automation(-caller)?\.yml|automation-template\.yml)$' pass_filenames: false diff --git a/automation-registry.yml b/automation-registry.yml index 6c54dcf..dd7c912 100644 --- a/automation-registry.yml +++ b/automation-registry.yml @@ -55,7 +55,7 @@ automations: secrets: LE_BOT_APP_ID: required LE_BOT_PRIVATE_KEY: required - SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: required + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: optional - name: holiday-message enabled: true leaf: holiday-message.yml @@ -68,7 +68,7 @@ automations: secrets: LE_BOT_APP_ID: required LE_BOT_PRIVATE_KEY: required - SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: required + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: optional - name: issue-open enabled: true leaf: manage-issue-header.yml @@ -109,9 +109,9 @@ automations: secrets: LE_BOT_APP_ID: required LE_BOT_PRIVATE_KEY: required - CONTRIBUTIONS_SPREADSHEET_ID: required - CONTRIBUTIONS_SHEET_NAME: required - GH_UPLOADER_GCP_SA_CREDENTIALS: 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 @@ -132,8 +132,8 @@ automations: secrets: LE_BOT_APP_ID: required LE_BOT_PRIVATE_KEY: required - SLACK_WEBHOOK_URL: required - SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: required + SLACK_WEBHOOK_URL: optional + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: optional - name: resolve-bot-pr-threads enabled: true leaf: resolve_bot_pr_threads.yml @@ -156,4 +156,4 @@ automations: secrets: LE_BOT_APP_ID: required LE_BOT_PRIVATE_KEY: required - SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: required + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: optional diff --git a/docs/automation.md b/docs/automation.md index d37cb03..5d170fe 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -25,7 +25,8 @@ 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 +- `.github/workflows/automation-caller.yml` - this repo's own copy of the template, so `.github` + runs the automations it publishes 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 @@ -34,19 +35,24 @@ 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 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. +No edits required. Then set the secrets: + +| Secret | Required | Purpose | +|--------|----------|---------| +| `LE_BOT_APP_ID` | yes | GitHub App ID for bot authentication | +| `LE_BOT_PRIVATE_KEY` | yes | GitHub App private key for bot authentication | +| `SLACK_WEBHOOK_URL` | no | Slack `#support-dev` channel webhook | +| `SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL` | no | Slack `#support-dev-notifications` channel webhook | +| `CONTRIBUTIONS_SPREADSHEET_ID` | no | Google Sheets spreadsheet ID for PR tracking | +| `CONTRIBUTIONS_SHEET_NAME` | no | Sheet name within the spreadsheet | +| `GH_UPLOADER_GCP_SA_CREDENTIALS` | no | GCP service account credentials for Sheets access | + +Every automation authenticates as the bot, so the two required secrets must be set. A repo that +skips an optional secret still runs the automations that do not need it, because each job receives +only its own secrets. + +The generated caller forwards every key. An absent secret therefore reaches the leaf workflow as an +empty string, and the one automation that needs it fails at run time. ## Adding or toggling an automation diff --git a/scripts/generate-automation.js b/scripts/generate-automation.js index 46e682c..939012f 100644 --- a/scripts/generate-automation.js +++ b/scripts/generate-automation.js @@ -30,6 +30,10 @@ const GENERATED_HEADER = (description) => `# This file is generated by scripts/g const DUMP_OPTS = { indent: 2, lineWidth: -1, noRefs: true, quotingType: '"' }; +const DEFAULT_PERMISSIONS = { contents: 'read' }; +const PERMISSION_RANK = { none: 0, read: 1, write: 2 }; +const ACRONYMS = { pr: 'PR' }; + // 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) { @@ -44,10 +48,25 @@ function loadRegistry() { function titleCase(name) { return name .split('-') - .map((w) => w[0].toUpperCase() + w.slice(1)) + .map((w) => ACRONYMS[w] || w[0].toUpperCase() + w.slice(1)) .join(' '); } +function unionPermissions(automations) { + const merged = {}; + for (const a of automations) { + for (const [scope, level] of Object.entries(a.permissions || DEFAULT_PERMISSIONS)) { + const current = merged[scope]; + if (current === undefined || PERMISSION_RANK[level] > PERMISSION_RANK[current]) { + merged[scope] = level; + } + } + } + const sorted = {}; + for (const scope of Object.keys(merged).sort()) sorted[scope] = merged[scope]; + return sorted; +} + // secretName -> { required: bool, usedBy: [automationName, ...] } function collectSecrets(automations) { const secrets = {}; @@ -78,7 +97,7 @@ function buildAutomationYml(automations, secrets) { if: `\${{ ${a.if} }}`, uses: `learningequality/.github/.github/workflows/${a.leaf}@main`, }; - job.permissions = a.permissions || { contents: 'read' }; + job.permissions = a.permissions || DEFAULT_PERMISSIONS; const secretNames = Object.keys(a.secrets || {}); if (secretNames.length) { job.secrets = {}; @@ -111,12 +130,13 @@ function unionOn(automations) { if (cron && !on.schedule.some((s) => s.cron === cron)) on.schedule.push({ cron }); } } else if (value && Array.isArray(value.types)) { + if (on[event] && !on[event].types) continue; 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] || {}; + on[event] = {}; } } } @@ -132,7 +152,7 @@ function buildTemplateYml(automations, secrets, description) { const doc = { name: 'Automation', on: unionOn(automations), - permissions: { contents: 'write', 'pull-requests': 'write' }, + permissions: unionPermissions(automations), jobs: { automation: { name: 'Run automations', From c1311d0393c1cd4d77e760dd0c5c4be114cf251a Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 15 Sep 2026 18:49:30 +0300 Subject: [PATCH 07/10] Refuse ambiguous event declarations and correct the secrets docs Treating a missing types: as all types was wrong for pull_request_target, where omitting types: means only opened, synchronize and reopened. A registry entry declaring pull_request_target without types: would have silently narrowed every consumer's triggers, which is the drift #86 exists to remove. The generator now refuses the mix in either order. Two entries that both declare the same event without types:, such as workflow_dispatch, stay valid. The docs claimed that every automation authenticates as the bot, but resolve-bot-pr-threads has no secrets and uses the default GITHUB_TOKEN. They also said that one automation fails when an optional secret is absent, where SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL reaches four of them. --- docs/automation.md | 11 ++++++----- scripts/generate-automation.js | 12 +++++++++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/automation.md b/docs/automation.md index 5d170fe..3f07e8d 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -47,12 +47,13 @@ No edits required. Then set the secrets: | `CONTRIBUTIONS_SHEET_NAME` | no | Sheet name within the spreadsheet | | `GH_UPLOADER_GCP_SA_CREDENTIALS` | no | GCP service account credentials for Sheets access | -Every automation authenticates as the bot, so the two required secrets must be set. A repo that -skips an optional secret still runs the automations that do not need it, because each job receives -only its own secrets. +Every automation except `resolve-bot-pr-threads` authenticates as the bot, so the two required +secrets must be set. `resolve-bot-pr-threads` uses the default `GITHUB_TOKEN` instead. -The generated caller forwards every key. An absent secret therefore reaches the leaf workflow as an -empty string, and the one automation that needs it fails at run time. +Optional means that you accept losing the automations that use the secret. It does not mean that +they degrade gracefully. The generated caller forwards every key, so an absent secret reaches the +leaf workflow as an empty string, and every automation that needs it fails at run time. +`SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL` reaches four of them. ## Adding or toggling an automation diff --git a/scripts/generate-automation.js b/scripts/generate-automation.js index 939012f..4c42459 100644 --- a/scripts/generate-automation.js +++ b/scripts/generate-automation.js @@ -52,6 +52,15 @@ function titleCase(name) { .join(' '); } +// Omitting types: means all activity types for issues, but only opened, +// synchronize and reopened for pull_request_target. +function mixedTypesError(event, name) { + return new Error( + `Event "${event}" is declared both with and without types: (automation "${name}"). ` + + 'Declare types: explicitly on every entry for that event.' + ); +} + function unionPermissions(automations) { const merged = {}; for (const a of automations) { @@ -130,12 +139,13 @@ function unionOn(automations) { if (cron && !on.schedule.some((s) => s.cron === cron)) on.schedule.push({ cron }); } } else if (value && Array.isArray(value.types)) { - if (on[event] && !on[event].types) continue; + if (on[event] && !on[event].types) throw mixedTypesError(event, a.name); on[event] = on[event] || { types: [] }; for (const t of value.types) { if (!on[event].types.includes(t)) on[event].types.push(t); } } else { + if (on[event] && on[event].types) throw mixedTypesError(event, a.name); on[event] = {}; } } From b3bbf6918e1b542288c196f89da57064c66e0374 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Wed, 16 Sep 2026 17:15:32 +0300 Subject: [PATCH 08/10] Drop the blank line above name: in generated files yamlfmt v0.16.0 strips the blank line between the header comment and the first key. le-utils pins that version and runs it over all files, so a consumer could not hold both an unedited copy of the template and a green lint job. This repo pins v0.14.0, which leaves the line alone, and excludes the generated files from yamlfmt anyway, so nothing here caught it. The generator now emits the header without the trailing blank line. All three generated files are byte-stable under both v0.14.0 and v0.16.0. --- .github/workflows/automation-caller.yml | 1 - .github/workflows/automation.yml | 1 - automation-template.yml | 1 - scripts/generate-automation.js | 1 - 4 files changed, 4 deletions(-) diff --git a/.github/workflows/automation-caller.yml b/.github/workflows/automation-caller.yml index 09898b3..fe65bae 100644 --- a/.github/workflows/automation-caller.yml +++ b/.github/workflows/automation-caller.yml @@ -3,7 +3,6 @@ # node scripts/generate-automation.js # # This repo's own automation caller - .github acts as consumer zero and stays in sync automatically. - name: Automation on: pull_request_target: diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index 7e58678..d15980b 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -3,7 +3,6 @@ # node scripts/generate-automation.js # # Reusable workflow: one job per automation, gated on the original triggering event/action. - name: Automation on: workflow_call: diff --git a/automation-template.yml b/automation-template.yml index aa524e2..8420469 100644 --- a/automation-template.yml +++ b/automation-template.yml @@ -4,7 +4,6 @@ # # 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: diff --git a/scripts/generate-automation.js b/scripts/generate-automation.js index 4c42459..03b2515 100644 --- a/scripts/generate-automation.js +++ b/scripts/generate-automation.js @@ -25,7 +25,6 @@ const GENERATED_HEADER = (description) => `# This file is generated by scripts/g # node scripts/generate-automation.js # # ${description} - `; const DUMP_OPTS = { indent: 2, lineWidth: -1, noRefs: true, quotingType: '"' }; From 5c97f476dc4ea0c01c224bf6da5b6ba80bf68cff Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Wed, 16 Sep 2026 17:27:05 +0300 Subject: [PATCH 09/10] Carry the zizmor dangerous-triggers ignore into the template zizmor reports pull_request_target as a dangerous trigger. The hand-written callers this template replaces each carry an inline ignore on their `on:` key, so consumers running zizmor pass today. The generated template carried none, which left those consumers a config-level ignore as the only option. That form matches by basename, so it would also exempt any other file named automation.yml, and it would silently cover workflow_run if one were ever added. The generator now emits the same inline ignore on the `on:` key of the template and this repo's caller. automation.yml is untouched, because workflow_call is not a dangerous trigger. --- .github/workflows/automation-caller.yml | 2 +- automation-template.yml | 2 +- scripts/generate-automation.js | 9 ++++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/automation-caller.yml b/.github/workflows/automation-caller.yml index fe65bae..18f4d6a 100644 --- a/.github/workflows/automation-caller.yml +++ b/.github/workflows/automation-caller.yml @@ -4,7 +4,7 @@ # # This repo's own automation caller - .github acts as consumer zero and stays in sync automatically. name: Automation -on: +on: # zizmor: ignore[dangerous-triggers] pull_request_target: types: - review_requested diff --git a/automation-template.yml b/automation-template.yml index 8420469..132d716 100644 --- a/automation-template.yml +++ b/automation-template.yml @@ -5,7 +5,7 @@ # 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: +on: # zizmor: ignore[dangerous-triggers] pull_request_target: types: - review_requested diff --git a/scripts/generate-automation.js b/scripts/generate-automation.js index 03b2515..39490d5 100644 --- a/scripts/generate-automation.js +++ b/scripts/generate-automation.js @@ -32,11 +32,14 @@ const DUMP_OPTS = { indent: 2, lineWidth: -1, noRefs: true, quotingType: '"' }; const DEFAULT_PERMISSIONS = { contents: 'read' }; const PERMISSION_RANK = { none: 0, read: 1, write: 2 }; const ACRONYMS = { pr: 'PR' }; +// zizmor reports pull_request_target as a dangerous trigger. The hand-written +// callers this template replaces carry the same inline ignore on their `on:` key. +const ZIZMOR_IGNORE = '# zizmor: ignore[dangerous-triggers]'; // 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 unquoteOnKey(content, suffix) { + return content.replace(/^"on":/m, suffix ? `on: ${suffix}` : 'on:'); } function loadRegistry() { @@ -170,7 +173,7 @@ function buildTemplateYml(automations, secrets, description) { }, }, }; - return unquoteOnKey(GENERATED_HEADER(description) + yaml.dump(doc, DUMP_OPTS)); + return unquoteOnKey(GENERATED_HEADER(description) + yaml.dump(doc, DUMP_OPTS), ZIZMOR_IGNORE); } function main() { From fc9c4eb2bd5bc29185984e5dcd89956e3a59ac52 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Wed, 16 Sep 2026 17:34:03 +0300 Subject: [PATCH 10/10] Disable holiday-message and make the generated on: block order stable holiday-message was enabled in the registry, but the leaf has no date guard and the message text names fixed dates in December and January. None of the eight consumer repos runs it today, so migrating with the flag on would have started posting a holiday notice year-round on every contributor pull request and every issue comment, in all eight. The flag is now false, to be flipped on before the holidays and off after them. Flipping it also reordered the template's on: keys, because the union was built in registry iteration order. A consumer holds a copied template, so a reshuffle with no functional change would still force all eight repos to re-copy it, twice a year. The union now sorts event keys and type lists, which makes the template byte-identical whether holiday-message is on or off. The event and type sets are unchanged. Only their order is. --- .github/workflows/automation-caller.yml | 28 ++++++++++++------------- .github/workflows/automation.yml | 16 +++----------- automation-registry.yml | 5 ++++- automation-template.yml | 28 ++++++++++++------------- scripts/generate-automation.js | 19 ++++++++++++++++- 5 files changed, 53 insertions(+), 43 deletions(-) diff --git a/.github/workflows/automation-caller.yml b/.github/workflows/automation-caller.yml index 18f4d6a..0bfb56a 100644 --- a/.github/workflows/automation-caller.yml +++ b/.github/workflows/automation-caller.yml @@ -5,31 +5,31 @@ # This repo's own automation caller - .github acts as consumer zero and stays in sync automatically. name: Automation on: # zizmor: ignore[dangerous-triggers] - pull_request_target: - types: - - review_requested - - labeled - - opened - - assigned - - unassigned - - closed - - reopened - - edited - - review_request_removed issue_comment: types: - created issues: types: + - assigned + - labeled - opened - reopened - - labeled - - unlabeled - - assigned - unassigned + - unlabeled pull_request_review_comment: types: - created + pull_request_target: + types: + - assigned + - closed + - edited + - labeled + - opened + - reopened + - review_request_removed + - review_requested + - unassigned schedule: - cron: 1 0 * * 1 workflow_dispatch: {} diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index d15980b..aa6c3d2 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -17,13 +17,13 @@ on: 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" + description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, 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" + description: "Forwarded to: review-requested, pull-request-label, dependabot-reviewer, contributor-pr-reply, 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" + description: "Forwarded to: contributor-pr-reply, contributor-issue-comment, unassign-inactive-issues" required: false SLACK_WEBHOOK_URL: description: "Forwarded to: contributor-issue-comment" @@ -66,16 +66,6 @@ jobs: 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) }} diff --git a/automation-registry.yml b/automation-registry.yml index dd7c912..4e5d72f 100644 --- a/automation-registry.yml +++ b/automation-registry.yml @@ -1,6 +1,7 @@ # Single source of truth for the automations exposed through automation.yml. # # Each entry describes one automation: +# enabled - false drops the automation from every generated file, org-wide # 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 @@ -57,7 +58,9 @@ automations: LE_BOT_PRIVATE_KEY: required SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: optional - name: holiday-message - enabled: true + # Seasonal. The leaf has no date guard, so this must be flipped on before + # the holidays and off after them. + enabled: false leaf: holiday-message.yml on: pull_request_target: diff --git a/automation-template.yml b/automation-template.yml index 132d716..3208db2 100644 --- a/automation-template.yml +++ b/automation-template.yml @@ -6,31 +6,31 @@ # No edits are needed - the on: block is the exhaustive union of every enabled automation. name: Automation on: # zizmor: ignore[dangerous-triggers] - pull_request_target: - types: - - review_requested - - labeled - - opened - - assigned - - unassigned - - closed - - reopened - - edited - - review_request_removed issue_comment: types: - created issues: types: + - assigned + - labeled - opened - reopened - - labeled - - unlabeled - - assigned - unassigned + - unlabeled pull_request_review_comment: types: - created + pull_request_target: + types: + - assigned + - closed + - edited + - labeled + - opened + - reopened + - review_request_removed + - review_requested + - unassigned schedule: - cron: 1 0 * * 1 workflow_dispatch: {} diff --git a/scripts/generate-automation.js b/scripts/generate-automation.js index 39490d5..269c19b 100644 --- a/scripts/generate-automation.js +++ b/scripts/generate-automation.js @@ -152,7 +152,24 @@ function unionOn(automations) { } } } - return on; + return sortOn(on); +} + +// Consumers hold a copied template, so key order must depend only on the set of +// enabled automations, never on their order in the registry. +function sortOn(on) { + const sorted = {}; + for (const event of Object.keys(on).sort()) { + const value = on[event]; + if (Array.isArray(value)) { + sorted[event] = [...value].sort((a, b) => a.cron.localeCompare(b.cron)); + } else if (value && value.types) { + sorted[event] = { types: [...value.types].sort() }; + } else { + sorted[event] = value; + } + } + return sorted; } function buildTemplateYml(automations, secrets, description) {