diff --git a/.github/workflows/automation-caller.yml b/.github/workflows/automation-caller.yml new file mode 100644 index 0000000..09898b3 --- /dev/null +++ b/.github/workflows/automation-caller.yml @@ -0,0 +1,51 @@ +# 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 +# +# This repo's own automation caller - .github acts as consumer zero and stays in sync automatically. + +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..b00bd97 --- /dev/null +++ b/.github/workflows/automation.yml @@ -0,0 +1,155 @@ +# 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: true + CONTRIBUTIONS_SPREADSHEET_ID: + description: "Forwarded to: update-pr-spreadsheet" + required: true + GH_UPLOADER_GCP_SA_CREDENTIALS: + description: "Forwarded to: update-pr-spreadsheet" + 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 + 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: true + SLACK_WEBHOOK_URL: + description: "Forwarded to: contributor-issue-comment" + required: true +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 + permissions: + contents: write + pull-requests: write + 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..f7d12d7 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(-caller)?\.yml|automation-template\.yml)$' + pass_filenames: false diff --git a/automation-registry.yml b/automation-registry.yml new file mode 100644 index 0000000..6c54dcf --- /dev/null +++ b/automation-registry.yml @@ -0,0 +1,159 @@ +# 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/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` +# 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: required + - 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: required + - 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: required + CONTRIBUTIONS_SHEET_NAME: required + GH_UPLOADER_GCP_SA_CREDENTIALS: required + - 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: required + SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL: required + - name: resolve-bot-pr-threads + enabled: true + leaf: resolve_bot_pr_threads.yml + permissions: + contents: write + pull-requests: write + 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: required 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..d37cb03 --- /dev/null +++ b/docs/automation.md @@ -0,0 +1,63 @@ +# 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, 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 +- `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 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 + +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..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 @@ -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..668850d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "devDependencies": { "eslint": "^8.57.0", "eslint-config-prettier": "^10.1.8", + "js-yaml": "^4.1.0", "prettier": "^3.8.1" } } diff --git a/scripts/generate-automation.js b/scripts/generate-automation.js new file mode 100644 index 0000000..46e682c --- /dev/null +++ b/scripts/generate-automation.js @@ -0,0 +1,180 @@ +#!/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, secrets) { + 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`, + }; + job.permissions = a.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 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) { + if (!on[event].types.includes(t)) on[event].types.push(t); + } + } else { + on[event] = on[event] || {}; + } + } + } + return on; +} + +function buildTemplateYml(automations, secrets, description) { + 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(description) + yaml.dump(doc, DUMP_OPTS)); +} + +function main() { + const check = process.argv.includes('--check'); + const automations = loadRegistry(); + + const secrets = collectSecrets(automations); + const targets = [ + [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) { + 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();