From 4e038d1f77adad7e4e3b573e85c27c463145ba6e Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Fri, 25 Sep 2026 10:50:11 +0300 Subject: [PATCH 01/10] Let a sync run be limited to one repo Testing the write path meant a token scoped to repos the developer owned, which is why testing needed a separate organization at all. With --only, and the matching workflow input, a run covers one repo and cannot reach another, so containment is an input rather than a credential someone has to build. The org listing is skipped entirely when only is set, rather than filtered afterwards, so there is no path to the other repos. A test fails if that listing is reached. docs/testing-automations.md describes both flows in test-actions, where the bot app and the secrets are already in place. --- .../workflows/sync-automation-template.yml | 9 +++- docs/automation.md | 5 ++ docs/testing-automations.md | 50 +++++++++++++++++++ scripts/sync-automation-template.js | 22 +++++--- scripts/sync-automation-template.test.js | 29 +++++++++++ 5 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 docs/testing-automations.md diff --git a/.github/workflows/sync-automation-template.yml b/.github/workflows/sync-automation-template.yml index d1fd0a5..d37d345 100644 --- a/.github/workflows/sync-automation-template.yml +++ b/.github/workflows/sync-automation-template.yml @@ -9,6 +9,10 @@ on: description: 'Report drift without opening pull requests' type: boolean default: false + only: + description: 'Limit the run to one repo, for testing' + type: string + default: '' push: branches: - main @@ -43,4 +47,7 @@ jobs: - name: Sync consumers env: GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} - run: node scripts/sync-automation-template.js ${{ inputs.dry_run && '--dry-run' || '' }} + run: | + node scripts/sync-automation-template.js \ + ${{ inputs.dry_run && '--dry-run' || '' }} \ + ${{ inputs.only && format('--only={0}', inputs.only) || '' }} diff --git a/docs/automation.md b/docs/automation.md index ed516b0..5ac2b67 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -123,3 +123,8 @@ a Changelog section whose Description is not the placeholder its own template sh plain explanation has no such section. For that repo the sync reads its template and fills each field, so the body is not a half-filled form. Nothing about that template is stored here, so it stays current as they change it. + +## Testing a change + +See [testing-automations.md](./testing-automations.md). Test in `learningequality/test-actions`, +where the bot app and the secrets are already in place. diff --git a/docs/testing-automations.md b/docs/testing-automations.md new file mode 100644 index 0000000..c15545c --- /dev/null +++ b/docs/testing-automations.md @@ -0,0 +1,50 @@ +# Testing the automations + +Test in [`learningequality/test-actions`](https://github.com/learningequality/test-actions). The bot +app is installed there and the secrets resolve, so there is nothing to set up. + +It holds a copy of `automation-template.yml` at `.github/workflows/automation.yml`, making it a +consumer like any other. Every automation runs there against the reusable workflows on `main`. + +## Testing an automation + +Trigger the event you want to test: open a pull request, comment on an issue, add a label, etc. Then +check the run in the Actions tab. + +Jobs that do not match the event are reported as skipped, which is normal. If everything is skipped, +the `if:` condition on each job did not match, so check the event first. + +`update-pr-spreadsheet` writes to a test sheet rather than the production sheet because +`CONTRIBUTIONS_SPREADSHEET_ID` and `CONTRIBUTIONS_SHEET_NAME` are set as repository secrets there. A +repository secret takes precedence over an organization secret with the same name. + +## Testing the sync workflow + +Run `Sync automation template` from the Actions tab with `only` set to `test-actions`. This limits +the run to one repo, so it cannot open a pull request anywhere else. Leave `dry_run` on to see what +it would do, or turn it off to let it open a pull request. + +Without `only`, the workflow considers every consumer in the organization, which is how it runs on +its schedule. + +To see it propose a change, modify `.github/workflows/automation.yml` in `test-actions` so that it +differs from the template, then run it with `only: test-actions`. + +## Testing a change to a reusable workflow + +Point a caller at your branch, for example: + +`uses: learningequality/.github/.github/workflows/automation.yml@my-branch` + +Put that caller at a path other than `.github/workflows/automation.yml`. The sync reads exactly that +path, so a branch-pinned caller there looks like drift and gets a pull request on every run. + +## Clearing up + +Close test pull requests and delete their branches. Leave `.github/workflows/automation.yml` in +place, since the repo is only a consumer while that file exists. + +## When this is not enough + +Testing against an app or secrets that are not in production requires a separate organization, with +its own app installed and its own secrets. Point the sync at it with `SYNC_ORG`. diff --git a/scripts/sync-automation-template.js b/scripts/sync-automation-template.js index 58b1ac0..0a28031 100644 --- a/scripts/sync-automation-template.js +++ b/scripts/sync-automation-template.js @@ -4,8 +4,9 @@ * drifted from automation-template.yml. * * Usage: - * node scripts/sync-automation-template.js open or update pull requests - * node scripts/sync-automation-template.js --dry-run report only, change nothing + * node scripts/sync-automation-template.js open or update pull requests + * node scripts/sync-automation-template.js --dry-run report only, change nothing + * node scripts/sync-automation-template.js --only=repo limit the run to one repo * * Requires GITHUB_TOKEN with contents:write and pull-requests:write on each * consumer repo. It never commits to a default branch and never merges. @@ -136,9 +137,14 @@ async function readCopy(api, repo, ref) { * skipped because Actions do not run on them, and forks because their copy * belongs to the upstream repo. */ -async function findConsumers(api) { +async function findConsumers(api, only) { const repos = []; - for (let page = 1; ; page += 1) { + if (only) { + const r = await api('GET', `/repos/${ORG}/${only}`); + if (!r.ok) throw new Error(`could not read ${only} (${detail(r)})`); + repos.push(r.data); + } + for (let page = 1; !only; page += 1) { const r = await api('GET', `/orgs/${ORG}/repos?per_page=100&type=all&page=${page}`); if (!r.ok) throw new Error(`could not list the org's repos (${detail(r)})`); repos.push(...r.data); @@ -272,7 +278,7 @@ async function syncRepo(api, consumer, template, { dryRun }) { } async function run(api, template, options) { - const consumers = await findConsumers(api); + const consumers = await findConsumers(api, options.only); const results = []; for (const consumer of consumers) { @@ -306,7 +312,11 @@ async function main() { process.exit(1); } const template = fs.readFileSync(TEMPLATE_PATH, 'utf8'); - const results = await run(httpApi(token), template, { dryRun: process.argv.includes('--dry-run') }); + const onlyArg = process.argv.find((a) => a.startsWith('--only=')); + const results = await run(httpApi(token), template, { + dryRun: process.argv.includes('--dry-run'), + only: onlyArg && onlyArg.slice('--only='.length), + }); process.exit(report(results) ? 1 : 0); } diff --git a/scripts/sync-automation-template.test.js b/scripts/sync-automation-template.test.js index 0d99dc4..5435c93 100644 --- a/scripts/sync-automation-template.test.js +++ b/scripts/sync-automation-template.test.js @@ -375,6 +375,35 @@ test('a stale branch is reset to base when no pull request is open', async () => assert.equal(reset.body.force, true); }); +test('only limits the run to that repo, and never lists the org', async () => { + const calls = []; + const others = [repo({ name: 'demo' }), repo({ name: 'production-repo' })]; + const routes = [...baseRoutes(TEMPLATE, others), ['GET =/repos/learningequality/demo', ok(repo())]]; + const results = await run(makeApi(routes, calls), TEMPLATE, { only: 'demo' }); + assert.deepEqual( + results.map((r) => r.repo), + ['demo'] + ); + assert.ok( + !calls.some((c) => c.url.includes('/orgs/')), + 'the org listing is the path to every other repo, so it must not be walked' + ); +}); + +test('without only, every repo in the listing is considered', async () => { + const others = [repo({ name: 'demo' }), repo({ name: 'production-repo' })]; + const results = await run(makeApi(baseRoutes(TEMPLATE, others)), TEMPLATE, {}); + assert.deepEqual( + results.map((r) => r.repo), + ['demo', 'production-repo'] + ); +}); + +test('only reports an error when the repo cannot be read', async () => { + const routes = [...baseRoutes(TEMPLATE), ['GET =/repos/learningequality/missing', fail(404, 'Not Found')]]; + await assert.rejects(() => run(makeApi(routes), TEMPLATE, { only: 'missing' }), /could not read missing/); +}); + test('findConsumers pages through the org listing', async () => { const calls = []; const many = Array.from({ length: 100 }, (_, i) => repo({ name: `r${i}` })); From 3a50c1ccb70ecc333b34a28dccaca7a60a5af883 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Fri, 25 Sep 2026 11:40:38 +0300 Subject: [PATCH 02/10] Document that contributor automations need an outside account Several automations gate on is-contributor, so they skip for anyone in the organization. Testing them in test-actions therefore needs a second GitHub account that is not a member, which needs no permissions, secrets or app of its own. For a pull request it forks and opens one back, and pull_request_target runs in test-actions with test-actions' secrets rather than the fork's. --- docs/testing-automations.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/testing-automations.md b/docs/testing-automations.md index c15545c..142394c 100644 --- a/docs/testing-automations.md +++ b/docs/testing-automations.md @@ -14,9 +14,15 @@ check the run in the Actions tab. Jobs that do not match the event are reported as skipped, which is normal. If everything is skipped, the `if:` condition on each job did not match, so check the event first. -`update-pr-spreadsheet` writes to a test sheet rather than the production sheet because -`CONTRIBUTIONS_SPREADSHEET_ID` and `CONTRIBUTIONS_SHEET_NAME` are set as repository secrets there. A -repository secret takes precedence over an organization secret with the same name. +### Automations that need an outside contributor + +Some automations run only when `is-contributor` is true, meaning the author is not a member of the organization. An org member cannot trigger them, so they skip. These are `review-requested`, `pull-request-label`, `contributor-pr-reply`, `contributor-issue-comment`, and `update-pr-spreadsheet`, plus `holiday-message` when enabled. + +Use a second GitHub account that is not in the organization. It needs no permissions, secrets, or app. For a pull request, it forks `test-actions` and opens a pull request back to it. `pull_request_target` then runs in `test-actions` with `test-actions`' secrets rather than the fork's. For an issue or comment, no fork is needed. + +Keep that account out of the organization. Adding it makes these automations skip again. + +`update-pr-spreadsheet` writes to a test sheet rather than the production sheet because `CONTRIBUTIONS_SPREADSHEET_ID` and `CONTRIBUTIONS_SHEET_NAME` are set as repository secrets there. A repository secret takes precedence over an organization secret with the same name. ## Testing the sync workflow From 487680a3a79bb7573f59ee7d3f43c76f92f4e784 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Fri, 25 Sep 2026 11:41:57 +0300 Subject: [PATCH 03/10] Wrap the contributor section to match the rest of the docs --- docs/testing-automations.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/testing-automations.md b/docs/testing-automations.md index 142394c..0dd557b 100644 --- a/docs/testing-automations.md +++ b/docs/testing-automations.md @@ -16,13 +16,21 @@ the `if:` condition on each job did not match, so check the event first. ### Automations that need an outside contributor -Some automations run only when `is-contributor` is true, meaning the author is not a member of the organization. An org member cannot trigger them, so they skip. These are `review-requested`, `pull-request-label`, `contributor-pr-reply`, `contributor-issue-comment`, and `update-pr-spreadsheet`, plus `holiday-message` when enabled. +Some automations run only when `is-contributor` is true, meaning the author is not a member of the +organization. An org member cannot trigger them, so they skip. These are `review-requested`, +`pull-request-label`, `contributor-pr-reply`, `contributor-issue-comment`, and +`update-pr-spreadsheet`, plus `holiday-message` when enabled. -Use a second GitHub account that is not in the organization. It needs no permissions, secrets, or app. For a pull request, it forks `test-actions` and opens a pull request back to it. `pull_request_target` then runs in `test-actions` with `test-actions`' secrets rather than the fork's. For an issue or comment, no fork is needed. +Use a second GitHub account that is not in the organization. It needs no permissions, secrets, or +app. For a pull request, it forks `test-actions` and opens a pull request back to it. +`pull_request_target` then runs in `test-actions` with `test-actions`' secrets rather than the +fork's. For an issue or comment, no fork is needed. Keep that account out of the organization. Adding it makes these automations skip again. -`update-pr-spreadsheet` writes to a test sheet rather than the production sheet because `CONTRIBUTIONS_SPREADSHEET_ID` and `CONTRIBUTIONS_SHEET_NAME` are set as repository secrets there. A repository secret takes precedence over an organization secret with the same name. +`update-pr-spreadsheet` writes to a test sheet rather than the production sheet because +`CONTRIBUTIONS_SPREADSHEET_ID` and `CONTRIBUTIONS_SHEET_NAME` are set as repository secrets there. A +repository secret takes precedence over an organization secret with the same name. ## Testing the sync workflow From da50b0bb9a48f1b739aa16d65e9eac6b8f402dab Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Fri, 25 Sep 2026 12:01:59 +0300 Subject: [PATCH 04/10] Name docs by their title rather than their filename in links The extension adds nothing a reader needs, and both internal links now read the same way. --- docs/automation.md | 2 +- docs/community-automations.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/automation.md b/docs/automation.md index 5ac2b67..847e5f8 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -126,5 +126,5 @@ stays current as they change it. ## Testing a change -See [testing-automations.md](./testing-automations.md). Test in `learningequality/test-actions`, +See [testing-automations](./testing-automations.md). Test in `learningequality/test-actions`, where the bot app and the secrets are already in place. diff --git a/docs/community-automations.md b/docs/community-automations.md index f18516f..3046b14 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, 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. +Before/after holidays, toggle `enabled:` for `holiday-message` in [`automation-registry.yml`](../automation-registry.yml) and regenerate (see [automation](./automation.md)) - no per-repo changes needed. From bf5facb0dacce6281fb6eb3258fc7452af493c5b Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Fri, 25 Sep 2026 12:22:42 +0300 Subject: [PATCH 05/10] Document setting up a separate org for testing The page said only that testing against a non-production app needs another organization, which glossed over registering an app, installing it, and creating a service account and a spreadsheet shared with it. Those steps now have their own page, linked from the testing page, along with the three cases where the route is worth the setup. Keeping them separate stops the longest instructions belonging to the least common path from dominating the page a developer reads first. --- docs/testing-automations.md | 7 ++--- docs/testing-in-a-separate-org.md | 44 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 docs/testing-in-a-separate-org.md diff --git a/docs/testing-automations.md b/docs/testing-automations.md index 0dd557b..24c1670 100644 --- a/docs/testing-automations.md +++ b/docs/testing-automations.md @@ -58,7 +58,8 @@ path, so a branch-pinned caller there looks like drift and gets a pull request o Close test pull requests and delete their branches. Leave `.github/workflows/automation.yml` in place, since the repo is only a consumer while that file exists. -## When this is not enough +## Testing in a separate organization -Testing against an app or secrets that are not in production requires a separate organization, with -its own app installed and its own secrets. Point the sync at it with `SYNC_ORG`. +Changing the bot app itself, using different secret values, or changing discovery across several +repos needs an organization you control. See +[testing-in-a-separate-org](./testing-in-a-separate-org.md). diff --git a/docs/testing-in-a-separate-org.md b/docs/testing-in-a-separate-org.md new file mode 100644 index 0000000..9daf399 --- /dev/null +++ b/docs/testing-in-a-separate-org.md @@ -0,0 +1,44 @@ +# Testing in a separate organization + +Most testing belongs in `learningequality/test-actions`, where the app and the secrets are already +in place. See [testing-automations](./testing-automations.md). + +A separate organization is worth the setup when: + +- You are changing the bot app itself, its permissions or its installation scope. Those cannot be + tried on the production app. +- You need different secret values, such as another Slack webhook, without changing what everyone + else testing in `test-actions` sees. +- You are changing discovery. `--only` covers one repo, while an organization lets you arrange + several: in sync, drifted, archived, and a fork. + +## Setting one up + +1. Create an organization, or use one you own. +2. Register a GitHub App in it, with repository permissions for contents, issues and pull requests + set to read and write, and metadata to read. Install it on the repos you will test with. +3. Generate a private key for the app. Set `LE_BOT_APP_ID` to the app's id and `LE_BOT_PRIVATE_KEY` + to the whole key file, at organization or repository level. +4. Add `SLACK_WEBHOOK_URL` and `SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL` if you want the Slack + automations. Without them those steps fail, and the rest still run. +5. For the spreadsheet, create a Google Cloud project, enable the Google Sheets API, and create a + service account. On that service account open the Keys tab, add a key, and choose JSON. Set + `GH_UPLOADER_GCP_SA_CREDENTIALS` to the whole file. +6. Create a Google Sheet and share it with the service account's `client_email` as an editor. That + sharing is the only permission that matters, since no IAM role is involved. Set + `CONTRIBUTIONS_SPREADSHEET_ID` to the id in the sheet's URL and `CONTRIBUTIONS_SHEET_NAME` to the + tab name. +7. Copy `automation-template.yml` into a repo there as `.github/workflows/automation.yml`. + +## Running against it + +Set `SYNC_ORG` to the organization: + +``` +SYNC_ORG= GITHUB_TOKEN= node scripts/sync-automation-template.js --dry-run +``` + +`SYNC_ORG` and `--only` compose, so a run can still be narrowed to one repo inside it. + +Installation is per repo and separate from the app's permissions. A repo the app is not installed on +never appears, because discovery only sees what the token can see. From 38c473434ab970512db3af9c0124ab7de210f4be Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Fri, 25 Sep 2026 12:24:54 +0300 Subject: [PATCH 06/10] Use each page's title as its link text A filename in the middle of a sentence tells a reader less than the title does, and reads worse. --- docs/automation.md | 2 +- docs/community-automations.md | 2 +- docs/testing-automations.md | 2 +- docs/testing-in-a-separate-org.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/automation.md b/docs/automation.md index 847e5f8..2ca1737 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -126,5 +126,5 @@ stays current as they change it. ## Testing a change -See [testing-automations](./testing-automations.md). Test in `learningequality/test-actions`, +See [testing the automations](./testing-automations.md). Test in `learningequality/test-actions`, where the bot app and the secrets are already in place. diff --git a/docs/community-automations.md b/docs/community-automations.md index 3046b14..3bb48da 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, toggle `enabled:` for `holiday-message` in [`automation-registry.yml`](../automation-registry.yml) and regenerate (see [automation](./automation.md)) - no per-repo changes needed. +Before/after holidays, toggle `enabled:` for `holiday-message` in [`automation-registry.yml`](../automation-registry.yml) and regenerate (see [the automation entry point](./automation.md)) - no per-repo changes needed. diff --git a/docs/testing-automations.md b/docs/testing-automations.md index 24c1670..13a9b43 100644 --- a/docs/testing-automations.md +++ b/docs/testing-automations.md @@ -62,4 +62,4 @@ place, since the repo is only a consumer while that file exists. Changing the bot app itself, using different secret values, or changing discovery across several repos needs an organization you control. See -[testing-in-a-separate-org](./testing-in-a-separate-org.md). +[testing in a separate organization](./testing-in-a-separate-org.md). diff --git a/docs/testing-in-a-separate-org.md b/docs/testing-in-a-separate-org.md index 9daf399..e84b8b3 100644 --- a/docs/testing-in-a-separate-org.md +++ b/docs/testing-in-a-separate-org.md @@ -1,7 +1,7 @@ # Testing in a separate organization Most testing belongs in `learningequality/test-actions`, where the app and the secrets are already -in place. See [testing-automations](./testing-automations.md). +in place. See [testing the automations](./testing-automations.md). A separate organization is worth the setup when: From 1ea196bfcd17b0706086a92c8a6d58991c597176 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Fri, 25 Sep 2026 12:27:02 +0300 Subject: [PATCH 07/10] Wrap the separate org page to match the rest of the docs --- docs/testing-in-a-separate-org.md | 38 +++++++++++++++---------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/testing-in-a-separate-org.md b/docs/testing-in-a-separate-org.md index e84b8b3..eb49313 100644 --- a/docs/testing-in-a-separate-org.md +++ b/docs/testing-in-a-separate-org.md @@ -1,32 +1,32 @@ # Testing in a separate organization -Most testing belongs in `learningequality/test-actions`, where the app and the secrets are already -in place. See [testing the automations](./testing-automations.md). +Most testing belongs in `learningequality/test-actions`, where the app and secrets are already in +place. See [testing the automations](./testing-automations.md). A separate organization is worth the setup when: -- You are changing the bot app itself, its permissions or its installation scope. Those cannot be - tried on the production app. -- You need different secret values, such as another Slack webhook, without changing what everyone +* You are changing the bot app itself, its permissions, or its installation scope. Those changes + cannot be tested on the production app. +* You need different secret values, such as another Slack webhook, without changing what everyone else testing in `test-actions` sees. -- You are changing discovery. `--only` covers one repo, while an organization lets you arrange - several: in sync, drifted, archived, and a fork. +* You are changing discovery. `--only` covers one repo, while an organization lets you arrange + several repos: in sync, drifted, archived, and a fork. ## Setting one up 1. Create an organization, or use one you own. -2. Register a GitHub App in it, with repository permissions for contents, issues and pull requests - set to read and write, and metadata to read. Install it on the repos you will test with. -3. Generate a private key for the app. Set `LE_BOT_APP_ID` to the app's id and `LE_BOT_PRIVATE_KEY` - to the whole key file, at organization or repository level. -4. Add `SLACK_WEBHOOK_URL` and `SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL` if you want the Slack - automations. Without them those steps fail, and the rest still run. +2. Register a GitHub App in it with repository permissions for contents, issues, and pull requests + set to read and write, and metadata set to read. Install it on the repos you will test with. +3. Generate a private key for the app. Set `LE_BOT_APP_ID` to the app's ID and `LE_BOT_PRIVATE_KEY` + to the contents of the key file, at the organization or repository level. +4. Add `SLACK_WEBHOOK_URL` and `SLACK_COMMUNITY_NOTIFICATIONS_WEBHOOK_URL` if you want to test the + Slack automations. Without them, those steps fail, but the rest still runs. 5. For the spreadsheet, create a Google Cloud project, enable the Google Sheets API, and create a - service account. On that service account open the Keys tab, add a key, and choose JSON. Set - `GH_UPLOADER_GCP_SA_CREDENTIALS` to the whole file. + service account. On the service account, open the Keys tab, add a key, and choose JSON. Set + `GH_UPLOADER_GCP_SA_CREDENTIALS` to the contents of the file. 6. Create a Google Sheet and share it with the service account's `client_email` as an editor. That sharing is the only permission that matters, since no IAM role is involved. Set - `CONTRIBUTIONS_SPREADSHEET_ID` to the id in the sheet's URL and `CONTRIBUTIONS_SHEET_NAME` to the + `CONTRIBUTIONS_SPREADSHEET_ID` to the ID in the sheet's URL and `CONTRIBUTIONS_SHEET_NAME` to the tab name. 7. Copy `automation-template.yml` into a repo there as `.github/workflows/automation.yml`. @@ -34,11 +34,11 @@ A separate organization is worth the setup when: Set `SYNC_ORG` to the organization: -``` +```bash SYNC_ORG= GITHUB_TOKEN= node scripts/sync-automation-template.js --dry-run ``` -`SYNC_ORG` and `--only` compose, so a run can still be narrowed to one repo inside it. +`SYNC_ORG` and `--only` compose, so a run can still be narrowed to one repo within the organization. Installation is per repo and separate from the app's permissions. A repo the app is not installed on -never appears, because discovery only sees what the token can see. +never appears because discovery only sees what the token can see. From 7f9a562732dc62f6e60c80edc9a25dc6eaa62701 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Fri, 25 Sep 2026 12:27:55 +0300 Subject: [PATCH 08/10] Wrap the separate org page to match the rest of the docs --- docs/testing-automations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/testing-automations.md b/docs/testing-automations.md index 13a9b43..8b190d0 100644 --- a/docs/testing-automations.md +++ b/docs/testing-automations.md @@ -60,6 +60,6 @@ place, since the repo is only a consumer while that file exists. ## Testing in a separate organization -Changing the bot app itself, using different secret values, or changing discovery across several -repos needs an organization you control. See +Changing the bot app, using different secret values, or testing discovery across several repos +requires an organization you control. See [testing in a separate organization](./testing-in-a-separate-org.md). From cad6b69a79fe79c478630e9ab2497f0b34581a59 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Fri, 25 Sep 2026 15:17:56 +0300 Subject: [PATCH 09/10] Accept both spellings of --only, and say why a named repo was skipped Only --only=repo was parsed, so --only repo fell through to a full run against every consumer, opening real pull requests. That is the spelling #98 uses, so following the issue produced the opposite of what it asked for. Both forms now work, and an --only with no repo name is an error rather than a full run. A named repo that is archived, a fork, or without the marker reported no consumers and exited 0, which reads as in sync. It now says which of those it was. Errors reaching the top print their message and exit 1, rather than a stack trace. The workflow passes the input through env instead of expanding it into the shell of a step holding an org-wide write token. Clearing up now says to merge the sync pull request or revert the drift. Closing it unmerged leaves the repo out of sync and reported as declined. --- .../workflows/sync-automation-template.yml | 6 +- docs/testing-automations.md | 4 ++ scripts/sync-automation-template.js | 58 +++++++++++++++---- scripts/sync-automation-template.test.js | 43 +++++++++++++- 4 files changed, 98 insertions(+), 13 deletions(-) diff --git a/.github/workflows/sync-automation-template.yml b/.github/workflows/sync-automation-template.yml index d37d345..195935a 100644 --- a/.github/workflows/sync-automation-template.yml +++ b/.github/workflows/sync-automation-template.yml @@ -47,7 +47,9 @@ jobs: - name: Sync consumers env: GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + ONLY: ${{ inputs.only }} + DRY_RUN: ${{ inputs.dry_run && 'yes' || '' }} run: | node scripts/sync-automation-template.js \ - ${{ inputs.dry_run && '--dry-run' || '' }} \ - ${{ inputs.only && format('--only={0}', inputs.only) || '' }} + ${DRY_RUN:+--dry-run} \ + ${ONLY:+--only="$ONLY"} diff --git a/docs/testing-automations.md b/docs/testing-automations.md index 8b190d0..69e0e9c 100644 --- a/docs/testing-automations.md +++ b/docs/testing-automations.md @@ -58,6 +58,10 @@ path, so a branch-pinned caller there looks like drift and gets a pull request o Close test pull requests and delete their branches. Leave `.github/workflows/automation.yml` in place, since the repo is only a consumer while that file exists. +If you drifted the copy to make the sync propose something, either merge the sync pull request or +revert the drift. Closing it without merging leaves test-actions out of sync, so the workflow +reports it as declined and stops proposing updates until the template changes. + ## Testing in a separate organization Changing the bot app, using different secret values, or testing discovery across several repos diff --git a/scripts/sync-automation-template.js b/scripts/sync-automation-template.js index 0a28031..826ff67 100644 --- a/scripts/sync-automation-template.js +++ b/scripts/sync-automation-template.js @@ -151,23 +151,38 @@ async function findConsumers(api, only) { if (r.data.length < 100) break; } + // Skipping a named repo would report no consumers and exit 0, which reads as + // "in sync". Say why instead. + const skip = (repo, why) => { + if (only) throw new Error(`${repo.name} is not a consumer: ${why}`); + }; + const consumers = []; for (const repo of repos) { - if (repo.archived || repo.fork) continue; + if (repo.archived || repo.fork) { + skip(repo, repo.archived ? 'archived' : 'a fork'); + continue; + } let copy; try { copy = await readCopy(api, repo.name, repo.default_branch); } catch (err) { copy = { error: err.message }; } - if (copy.missing) continue; + if (copy.missing) { + skip(repo, `no ${TARGET_PATH}`); + continue; + } if (copy.error) { consumers.push({ repo: repo.name, base: repo.default_branch, unreadable: copy.error }); continue; } // This repo's own reusable automation.yml sits at the same path and does not // call the shared workflow, so this excludes it without a special case. - if (!copy.content.includes(CONSUMER_MARKER)) continue; + if (!copy.content.includes(CONSUMER_MARKER)) { + skip(repo, 'its automation.yml does not call the shared workflow'); + continue; + } consumers.push({ repo: repo.name, base: repo.default_branch }); } return consumers; @@ -305,6 +320,18 @@ function report(results) { return problems.length; } +// Both spellings, because an --only nobody parses is a full run against every +// consumer, which is the opposite of what it asks for. +function parseArgs(argv) { + const i = argv.findIndex((a) => a === '--only' || a.startsWith('--only=')); + let only; + if (i !== -1) { + only = argv[i] === '--only' ? argv[i + 1] : argv[i].slice('--only='.length); + if (!only || only.startsWith('--')) throw new Error('--only needs a repo name'); + } + return { dryRun: argv.includes('--dry-run'), only }; +} + async function main() { const token = process.env.GITHUB_TOKEN; if (!token) { @@ -312,14 +339,25 @@ async function main() { process.exit(1); } const template = fs.readFileSync(TEMPLATE_PATH, 'utf8'); - const onlyArg = process.argv.find((a) => a.startsWith('--only=')); - const results = await run(httpApi(token), template, { - dryRun: process.argv.includes('--dry-run'), - only: onlyArg && onlyArg.slice('--only='.length), - }); + const results = await run(httpApi(token), template, parseArgs(process.argv.slice(2))); process.exit(report(results) ? 1 : 0); } -if (require.main === module) main(); +if (require.main === module) { + main().catch((err) => { + console.error(err.message); + process.exit(1); + }); +} -module.exports = { run, syncRepo, classifyDrift, findConsumers, report, PROBLEM_STATES, BRANCH, TARGET_PATH }; +module.exports = { + run, + syncRepo, + classifyDrift, + findConsumers, + parseArgs, + report, + PROBLEM_STATES, + BRANCH, + TARGET_PATH, +}; diff --git a/scripts/sync-automation-template.test.js b/scripts/sync-automation-template.test.js index 5435c93..c92f413 100644 --- a/scripts/sync-automation-template.test.js +++ b/scripts/sync-automation-template.test.js @@ -1,7 +1,15 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { run, classifyDrift, findConsumers, report, PROBLEM_STATES, BRANCH } = require('./sync-automation-template'); +const { + run, + classifyDrift, + findConsumers, + parseArgs, + report, + PROBLEM_STATES, + BRANCH, +} = require('./sync-automation-template'); const USES = 'jobs:\n automation:\n uses: learningequality/.github/.github/workflows/automation.yml@main\n'; const TEMPLATE = `name: Automation\non: {}\n${USES}`; @@ -375,6 +383,39 @@ test('a stale branch is reset to base when no pull request is open', async () => assert.equal(reset.body.force, true); }); +test('both spellings of --only are parsed, because an ignored one runs against everything', () => { + assert.equal(parseArgs(['--only=demo']).only, 'demo'); + assert.equal(parseArgs(['--only', 'demo']).only, 'demo'); + assert.equal(parseArgs(['--dry-run', '--only', 'demo']).dryRun, true); + assert.equal(parseArgs(['--dry-run']).only, undefined); +}); + +test('--only without a repo name is an error, not a full run', () => { + assert.throws(() => parseArgs(['--only']), /needs a repo name/); + assert.throws(() => parseArgs(['--only=']), /needs a repo name/); + assert.throws(() => parseArgs(['--only', '--dry-run']), /needs a repo name/); +}); + +test('a named repo that is not a consumer says why, rather than reporting none', async () => { + const cases = [ + [repo({ archived: true }), /archived/], + [repo({ fork: true }), /a fork/], + ]; + for (const [r, expected] of cases) { + const routes = [...baseRoutes(TEMPLATE), ['GET =/repos/learningequality/demo', ok(r)]]; + await assert.rejects(() => run(makeApi(routes), TEMPLATE, { only: 'demo' }), expected); + } +}); + +test('a named repo without the marker says why', async () => { + const routes = [ + ...baseRoutes(TEMPLATE), + ['GET =/repos/learningequality/demo', ok(repo())], + ['GET contents/.github/workflows/automation.yml', ok({ sha: 'x', content: encode('name: other\n') })], + ]; + await assert.rejects(() => run(makeApi(routes), TEMPLATE, { only: 'demo' }), /does not call the shared workflow/); +}); + test('only limits the run to that repo, and never lists the org', async () => { const calls = []; const others = [repo({ name: 'demo' }), repo({ name: 'production-repo' })]; From ad4e772af4a5c5a2ad8cf22801e443a648c3cd31 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Fri, 25 Sep 2026 15:50:09 +0300 Subject: [PATCH 10/10] Tell the reader what to do, rather than describe the account The surrounding steps are instructions, so the fork step reads as one too. --- docs/testing-automations.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/testing-automations.md b/docs/testing-automations.md index 69e0e9c..70d873b 100644 --- a/docs/testing-automations.md +++ b/docs/testing-automations.md @@ -22,9 +22,9 @@ organization. An org member cannot trigger them, so they skip. These are `review `update-pr-spreadsheet`, plus `holiday-message` when enabled. Use a second GitHub account that is not in the organization. It needs no permissions, secrets, or -app. For a pull request, it forks `test-actions` and opens a pull request back to it. -`pull_request_target` then runs in `test-actions` with `test-actions`' secrets rather than the -fork's. For an issue or comment, no fork is needed. +app. To open a pull request, fork `test-actions` and open one back to it. `pull_request_target` then +runs in `test-actions` with `test-actions`' secrets rather than the fork's. For an issue or comment, +no fork is needed. Keep that account out of the organization. Adding it makes these automations skip again.