From feebccbc65dfe2d093725f9678b5d2dc9be5500a Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Wed, 24 Jun 2026 16:15:19 +0300 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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 6/7] 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 7/7] 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] = {}; } }