Fork Triggered Course Start #24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Fork Triggered Course Start | |
| on: | |
| fork: | |
| jobs: | |
| start-course: | |
| runs-on: ubuntu-latest | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| steps: | |
| - name: Checkout course content | |
| uses: actions/checkout@v5 | |
| with: | |
| sparse-checkout: | | |
| .github/scripts | |
| content/steps | |
| .course_config/questions | |
| sparse-checkout-cone-mode: false | |
| - name: Create enrollment issue, tracking issue, and fork archive | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.GH_PAT_CROSSREPO }} | |
| script: | | |
| const helpers = require('./.github/scripts/github-helpers.js'); | |
| const participant = context.actor; | |
| const forkRepo = context.payload.forkee.full_name; | |
| const [forkOwner, forkName] = forkRepo.split('/'); | |
| const forkInfo = await github.rest.repos.get({ owner: forkOwner, repo: forkName }); | |
| const defaultBranch = context.payload.repository.default_branch; | |
| const defaultRef = await github.rest.git.getRef({ | |
| owner: forkOwner, | |
| repo: forkName, | |
| ref: `heads/${forkInfo.data.default_branch}` | |
| }); | |
| const forkSha = defaultRef.data.object.sha; | |
| const upstreamRepo = `${context.repo.owner}/${context.repo.repo}`; | |
| const imageBaseUrl = `https://raw.githubusercontent.com/${upstreamRepo}/${defaultBranch}/images`; | |
| // === Close any existing open tracking/enrollment issues for this participant === | |
| // This handles the re-fork case: student deletes fork and forks again. | |
| // Old tracking issues must have their new_participant label removed so | |
| // continue.yml no longer fires on them (GitHub fires issue_comment on | |
| // closed issues too, so closing alone is not sufficient). | |
| const existingIssues = await github.paginate(github.rest.issues.listForRepo, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| labels: 'new_participant', | |
| per_page: 100 | |
| }); | |
| for (const oldIssue of existingIssues) { | |
| const oldBody = oldIssue.body || ''; | |
| const oldParticipantMatch = oldBody.match(/^Participant:\s*`([^`]+)`\s*$/m); | |
| if (!oldParticipantMatch) continue; | |
| if (oldParticipantMatch[1].toLowerCase() !== participant.toLowerCase()) continue; | |
| // Remove new_participant label first to disable continue.yml routing on this issue | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: oldIssue.number, | |
| name: 'new_participant' | |
| }); | |
| } catch (labelErr) { | |
| core.warning(`Could not remove label from old tracking issue #${oldIssue.number}: ${labelErr.message}`); | |
| } | |
| // Close linked enrollment issue if present | |
| const oldEnrollmentMatch = oldBody.match(/Enrollment issue:\s*#(\d+)/); | |
| if (oldEnrollmentMatch) { | |
| const oldEnrollmentNumber = parseInt(oldEnrollmentMatch[1], 10); | |
| try { | |
| await github.rest.issues.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: oldEnrollmentNumber, | |
| state: 'closed', | |
| state_reason: 'not_planned' | |
| }); | |
| } catch (e) { | |
| core.warning(`Could not close old enrollment issue #${oldEnrollmentNumber}: ${e.message}`); | |
| } | |
| } | |
| // Close old tracking issue with a redirect note | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: oldIssue.number, | |
| body: `@${participant} has started a new course session by re-forking the repository. This tracking issue is now superseded and has been closed. Your new course tracking issue will be created momentarily — look for an issue titled **\`@${participant} started the Git/GitHub interactive course\`** in the Issues tab.` | |
| }); | |
| await github.rest.issues.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: oldIssue.number, | |
| state: 'closed', | |
| state_reason: 'not_planned' | |
| }); | |
| } | |
| // === Object 1: Enrollment record in upstream (instructor dashboard) === | |
| const enrollmentBodyInitial = [ | |
| `## Course Enrollment: @${participant}`, | |
| '', | |
| `@${participant} has started the interactive Git and GitHub course.`, | |
| '', | |
| `- **GitHub profile:** https://github.com/${participant}`, | |
| `- **Fork:** https://github.com/${forkOwner}/${forkName}`, | |
| '', | |
| '---', | |
| '', | |
| '> **⚠️ Are you a course participant who found this issue?**', | |
| '>', | |
| '> This is an automatic enrollment record. **Your interactive course is not here.**', | |
| '>', | |
| `> Your personal course tracking issue is titled: **\`@${participant} started the Git/GitHub interactive course\`**`, | |
| '>', | |
| '> Please make sure the username in that issue title matches **your own** GitHub username.', | |
| '> Different participants each have their own separate issue — do not interact with someone else\'s.', | |
| '>', | |
| '> **Direct link to your course:** *(will be added in a moment)*', | |
| '', | |
| '---', | |
| '', | |
| `This enrollment record will be automatically closed when @${participant} completes the course.`, | |
| ].join('\n'); | |
| const enrollmentIssue = await helpers.createIssue({ | |
| github, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title: `[Enrollment] @${participant}`, | |
| body: enrollmentBodyInitial, | |
| labels: ['enrollment'] | |
| }); | |
| const enrollmentNumber = enrollmentIssue.data.number; | |
| // === Object 2: Course tracking issue in upstream (student-facing) === | |
| const trackingBody = [ | |
| `Hi @${participant}!`, | |
| '', | |
| 'Welcome to the interactive Git and GitHub course.', | |
| '', | |
| `Participant: \`${participant}\``, | |
| `Fork repo: \`${forkRepo}\``, | |
| `Fork sha: \`${forkSha}\``, | |
| `Enrollment issue: #${enrollmentNumber}`, | |
| `Fork archive: pending`, | |
| '', | |
| 'This issue tracks your progress through the course.', | |
| 'Post your answer and the `/done N` command in a comment to advance. The workflow will reply with feedback and the next step. Please do not edit or delete the following lines.', | |
| '', | |
| '> ⚠️ **Please keep this issue open.** Do not click "Close issue". If you accidentally close it, simply reopen it — no progress is lost and the course continues from exactly where you left off.', | |
| '', | |
| '- [ ] 1. Git and version control basics', | |
| '- [ ] 2. GitHub — remote repositories and online collaboration', | |
| '- [ ] 3. Branches, collaboration, and open science', | |
| '- [ ] 4. Advanced Git, GitHub tools, and reproducibility' | |
| ].join('\n'); | |
| const trackingIssue = await helpers.createIssue({ | |
| github, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title: `@${participant} started the Git/GitHub interactive course`, | |
| body: trackingBody, | |
| labels: ['new_participant'] | |
| }); | |
| const trackingIssueNumber = trackingIssue.data.number; | |
| const trackingIssueUrl = trackingIssue.data.html_url; | |
| // Update enrollment issue body with actual tracking issue URL | |
| const enrollmentBodyFinal = enrollmentBodyInitial.replace( | |
| '> **Direct link to your course:** *(will be added in a moment)*', | |
| `> **→ Direct link to your course: ${trackingIssueUrl}**` | |
| ); | |
| await github.rest.issues.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: enrollmentNumber, | |
| body: enrollmentBodyFinal | |
| }); | |
| // === Object 3: Fork archive issue === | |
| // We do NOT attempt to create this at fork time because GitHub disables | |
| // Issues in all new forks by default. The student will enable Issues after | |
| // reading the instructions below. The archive issue is created lazily by | |
| // 1.js when step 1 is completed, by which time Issues should be enabled. | |
| // The tracking body keeps "Fork archive: pending" until then. | |
| // === Comment 1: Issues activation instructions === | |
| await helpers.createComment({ | |
| github, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: trackingIssueNumber, | |
| body: [ | |
| '---', | |
| '', | |
| '## Enable Issues in Your Fork to Receive a Personal Archive', | |
| '', | |
| 'As you complete each step, the course **can potentially** save a copy of the teaching materials in an issue on **your own fork**.', | |
| 'This gives you a personal archive to refer back to later.', | |
| '', | |
| 'For this to work, Issues must be enabled in **your fork** (not in this repository).', | |
| '', | |
| '### How to enable Issues in your fork', | |
| '', | |
| `1. Go to your fork: **https://github.com/${forkOwner}/${forkName}** (open in a new tab so that once done you can continue the interactive course in the instructor's repository)`, | |
| '2. Click the **Settings** tab at the top of the repository page', | |
| '3. Scroll down to the **Features** section', | |
| '4. Check the box next to **Issues**', | |
| '', | |
| ``, | |
| '', | |
| '> **Please enable Issues before completing Step 1.** The archive is created automatically when you submit your first valid answer. If you enable it later, the archive will be created when you complete the next step, and only that step and subsequent steps\' content will be saved.', | |
| '', | |
| '**Your progress in this tracking issue is not affected either way** — the archive is a personal reference only.', | |
| '', | |
| '---', | |
| ].join('\n') | |
| }); | |
| // === Comment 2: Step 1 teaching content === | |
| const variables = helpers.buildTemplateVariables({ | |
| context, | |
| forkRepo, | |
| trackingIssueUrl, | |
| participant, | |
| defaultBranch | |
| }); | |
| const stepOne = helpers.loadStepMarkdown(1, variables); | |
| await helpers.createComment({ | |
| github, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: trackingIssueNumber, | |
| body: [ | |
| 'Step 1 materials are ready below.', | |
| '', | |
| stepOne | |
| ].join('\n') | |
| }); |