Skip to content

Provision Learning Rooms (owned, GitHub-native) #318

Provision Learning Rooms (owned, GitHub-native)

Provision Learning Rooms (owned, GitHub-native) #318

name: Provision Learning Rooms (owned, GitHub-native)
# Owned, GitHub-native replacement for GitHub Classroom provisioning.
# Implements SPEC.md section 7. The roster of record lives in a private admin
# repository; this workflow syncs enrollment intake issues into the roster,
# idempotently provisions every pending or failed learner, and commits the
# updated roster plus the provisioning log back.
#
# Nothing here is destructive: re-running heals partial failures and never
# corrupts an existing student repository (idempotent on github_handle+cohort).
#
# Triggers:
# - repository_dispatch (provision-learning-rooms): fired by the registration
# workflow when a new enrollment lands, so learners get rooms in minutes.
# - schedule: a healing sweep a few times per day that re-syncs intake and
# retries any failed entries.
# - workflow_dispatch: manual runs, with an optional dry-run.
on:
workflow_dispatch:
inputs:
dry_run:
description: List who would be provisioned without making changes
type: boolean
default: false
repository_dispatch:
types: [provision-learning-rooms]
schedule:
# Healing sweep. Event-driven provisioning (repository_dispatch above)
# covers the fast path, so the cron only needs to catch stragglers.
- cron: '17 */6 * * *'
# Least privilege: contents read to run the scripts; issues write only for the
# watchdog alert issue in this repository. All student-repo writes happen via
# the GitHub App installation token configured in the provisioning environment.
permissions:
contents: read
issues: write
concurrency:
group: provision-learning-rooms
cancel-in-progress: false
jobs:
provision:
name: Provision learning rooms
runs-on: ubuntu-latest
environment: provisioning
steps:
- name: Check out workshop repo (provisioning scripts)
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Check out admin roster repo
uses: actions/checkout@v4
with:
repository: ${{ vars.ADMIN_ROSTER_REPO }}
token: ${{ secrets.PRIVATE_STUDENT_DATA_TOKEN }}
path: _roster
- name: Sync enrollment intake issues into the roster
if: ${{ !inputs.dry_run }}
run: |
node .github/scripts/provisioning/sync-intake-to-roster.js \
--roster _roster/roster.json
env:
INTAKE_REPO: ${{ vars.ADMIN_ROSTER_REPO }}
INTAKE_TOKEN: ${{ secrets.PRIVATE_STUDENT_DATA_TOKEN }}
PROVISIONING_COHORT_ID: ${{ vars.PROVISIONING_COHORT_ID }}
GITHUB_API_URL: ${{ github.api_url }}
- name: Count pending work
id: pending
run: |
count=$(node -e "
const fs = require('node:fs');
const { parseRoster, entriesToProvision } = require('./.github/scripts/provisioning/roster');
const roster = parseRoster(fs.readFileSync('_roster/roster.json', 'utf8'));
console.log(entriesToProvision(roster).length);
")
echo "count=$count" >> "$GITHUB_OUTPUT"
echo "$count learner(s) pending or failed."
- name: Provision (dry run)
if: ${{ inputs.dry_run }}
run: |
node .github/scripts/provisioning/provision-cli.js \
--roster _roster/roster.json --dry-run
env:
LEARNING_ROOM_TEMPLATE_REPO: ${{ vars.LEARNING_ROOM_TEMPLATE_REPO }}
PROVISIONING_STUDENT_OWNER: ${{ vars.PROVISIONING_STUDENT_OWNER }}
- name: Provision learners
if: ${{ !inputs.dry_run && steps.pending.outputs.count != '0' }}
run: |
node .github/scripts/provisioning/provision-cli.js \
--roster _roster/roster.json \
--log _roster/provisioning-log.json
env:
PROVISIONING_MODE: ${{ vars.PROVISIONING_MODE }}
LEARNING_ROOM_TEMPLATE_REPO: ${{ vars.LEARNING_ROOM_TEMPLATE_REPO }}
PROVISIONING_STUDENT_OWNER: ${{ vars.PROVISIONING_STUDENT_OWNER }}
GITHUB_API_URL: ${{ github.api_url }}
PROVISIONING_APP_ID: ${{ secrets.PROVISIONING_APP_ID }}
PROVISIONING_APP_PRIVATE_KEY: ${{ secrets.PROVISIONING_APP_PRIVATE_KEY }}
# Optional: discovered from the App installations when unset.
PROVISIONING_APP_INSTALLATION_ID: ${{ secrets.PROVISIONING_APP_INSTALLATION_ID }}
# Runs even when provisioning failed: per-learner failed states and the
# provisioning log are exactly what a facilitator needs to see.
- name: Commit updated roster and provisioning log
if: ${{ always() && !inputs.dry_run }}
working-directory: _roster
run: |
if [ -n "$(git status --porcelain)" ]; then
git config user.name "glow-provisioning[bot]"
git config user.email "provisioning@users.noreply.github.com"
git add roster.json provisioning-log.json
git commit -m "chore(provisioning): update roster and provisioning log"
git push
else
echo "No roster changes to commit."
fi
# Watchdog (SPEC.md section 7.3): any failure must be visible to a human
# before a learner notices. Opens or updates a pinned alert issue.
- name: Open or update provisioning alert issue
if: ${{ failure() }}
uses: actions/github-script@v8
with:
script: |
const label = 'provisioning-alert';
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
color: 'b60205',
description: 'Automated alert: learning room provisioning is failing'
});
} catch (e) { /* label already exists */ }
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const open = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: label,
state: 'open',
per_page: 1
});
if (open.data.length) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: open.data[0].number,
body: `Provisioning failed again: ${runUrl}`
});
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: 'Learning room provisioning is failing',
labels: [label],
body: [
'## Provisioning watchdog',
'',
'The Provision Learning Rooms workflow failed. Learners may be waiting on repositories.',
'',
`- Failed run: ${runUrl}`,
'- Runbook: admin/OWNED_PROVISIONING.md',
'',
'This issue closes automatically on the next successful run.'
].join('\n')
});
}
- name: Close provisioning alert issue on success
if: ${{ success() }}
uses: actions/github-script@v8
with:
script: |
const open = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'provisioning-alert',
state: 'open',
per_page: 10
});
for (const issue of open.data) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: 'Provisioning succeeded; closing this alert.'
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'completed'
});
}