LFX Export #10
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: LFX Export | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| term: | |
| description: > | |
| Term to export (must match the dropdown value in the issue form, | |
| e.g. "2026 Term 3 (Sep-Nov)") | |
| required: true | |
| type: choice | |
| options: | |
| - "2026 Term 3 (Sep-Nov)" | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| env: | |
| # Single source of truth for the bot commit identity (peter-evans author + | |
| # committer); keeps human attribution off bot-created commits. | |
| BOT_IDENTITY: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" | |
| jobs: | |
| export: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Export approved proposals | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const _lib = (m) => require(path.join(process.env.GITHUB_WORKSPACE || process.cwd(), 'programs/lfx-mentorship/automation/lib', m)); | |
| const { parseIssueForm, parseCheckboxes, parseMentors } = _lib('parse.js'); | |
| const { standardPrerequisites } = _lib('prerequisites.js'); | |
| const { buildReadme, renderAcceptedProgramsBody } = _lib('readme.js'); | |
| const { renderTrackingCsv } = _lib('csv.js'); | |
| const { serializeExport, partitionExportChanges } = _lib('export-json.js'); | |
| const { parseRecordedLfxUrl, exportChangeLabel, renderExportChangeBody } = _lib('lfx-url.js'); | |
| const { termPaths } = _lib('term-paths.js'); | |
| const { lookupProject } = _lib('projects.js'); | |
| const { lfxTitle } = _lib('title.js'); | |
| const { owner, repo } = context.repo; | |
| const term = context.payload.inputs.term; | |
| console.log(`Exporting proposals for: ${term}`); | |
| // ── Fetch all approved issues for this term ── | |
| const issues = await github.paginate(github.rest.issues.listForRepo, { | |
| owner, repo, state: 'open', | |
| labels: 'lfx mentorship,Proposal,CNCF Approved', | |
| per_page: 100, | |
| }); | |
| const termIssues = issues.filter(iss => { | |
| const body = iss.body || ''; | |
| return body.includes(`### Term\n\n${term}`); | |
| }); | |
| console.log(`Found ${termIssues.length} approved proposals for ${term}`); | |
| if (termIssues.length === 0) { | |
| core.setFailed(`No CNCF-approved proposals found for term: ${term}`); | |
| return; | |
| } | |
| // ── Load projects.yml for metadata ── | |
| const projYaml = fs.readFileSync('programs/lfx-mentorship/automation/projects.yml', 'utf8'); | |
| // ── Build export records ── | |
| const programs = []; | |
| for (const iss of termIssues) { | |
| const f = parseIssueForm(iss.body || ''); | |
| const get = (k) => (f[k] || '').trim(); | |
| const project = get('CNCF Project'); | |
| const programName = get('Program Name'); | |
| const fullName = lfxTitle({ project, programName, term }); | |
| const skillsSame = (get('Skills same as Technologies?') || '') | |
| .match(/\[x\]/i); | |
| const technologies = get('Technologies'); | |
| const skills = skillsSame ? technologies : get('Required/Desirable Skills'); | |
| const prerequisites = parseCheckboxes(get('Application Prerequisites')); | |
| const customPrereqChecked = prerequisites.includes( | |
| 'Custom Prerequisite (fill in details below)' | |
| ); | |
| const customFileUpload = (get('Custom Prerequisite — File Upload') || '') | |
| .match(/\[x\]/i); | |
| const meta = lookupProject(projYaml, project) || {}; | |
| // Read the LFX URL recorded on the issue by /lfx-url (§4.3.5), if | |
| // any. The issue is the source of truth, so a re-export keeps the | |
| // URL without re-keying it. Skip the API call when the issue has | |
| // no comments (listForRepo returns the count) to avoid needless | |
| // requests and secondary rate-limit pressure on large terms. | |
| let issueComments = []; | |
| if (iss.comments > 0) { | |
| try { | |
| issueComments = await github.paginate(github.rest.issues.listComments, { | |
| owner, repo, issue_number: iss.number, per_page: 100, | |
| }); | |
| } catch (e) { | |
| core.warning(`Could not read comments for #${iss.number}: ${e.message}`); | |
| } | |
| } | |
| const lfxUrl = parseRecordedLfxUrl(issueComments); | |
| programs.push({ | |
| issue_number: iss.number, | |
| issue_url: iss.html_url, | |
| cncf_project: project, | |
| cncf_project_slug: meta.slug || '', | |
| cncf_project_maturity: meta.maturity || '', | |
| term: term, | |
| program_name_full: fullName, | |
| program_name_short: programName, | |
| description: get('Program Description'), | |
| technologies: technologies, | |
| skills: skills, | |
| mentors: parseMentors(get('Mentors')), | |
| upstream_issue_url: get('Upstream Issue URL'), | |
| lfx_url: lfxUrl, | |
| prerequisites: { | |
| resume: prerequisites.includes('Resume'), | |
| cover_letter: prerequisites.includes('Cover Letter'), | |
| school_enrollment: prerequisites.includes('School Enrollment Verification'), | |
| participation_permission: prerequisites.includes( | |
| 'Participation Permission from school or employer' | |
| ), | |
| coding_challenge: prerequisites.includes('Coding Challenge'), | |
| coding_challenge_url: get('Coding Challenge URL') || null, | |
| custom: customPrereqChecked ? { | |
| name: get('Custom Prerequisite Name') || null, | |
| description: get('Custom Prerequisite Description') || null, | |
| file_upload: !!customFileUpload, | |
| } : null, | |
| standard_courses: standardPrerequisites(), | |
| }, | |
| }); | |
| } | |
| // ── Determine output path ── | |
| // Term format: "2026 Term 3 (Sep-Nov)" → year=2026, dir=03-Sep-Nov, | |
| // readmeTitle="Term 03 - 2026 September - November" (all derivation | |
| // shared with /lfx-url via lib/term-paths.js). | |
| const { year, termDir, outDir, readmeTitle } = termPaths(term); | |
| fs.mkdirSync(outDir, { recursive: true }); | |
| const outPath = `${outDir}/lfx-export.json`; | |
| const exportData = { | |
| _generated: new Date().toISOString(), | |
| _term: term, | |
| _count: programs.length, | |
| programs: programs, | |
| }; | |
| // Preserve the prior _generated when nothing else changed, so a | |
| // re-run with no substantive change stays byte-identical and the | |
| // change check below skips the no-op PR (#1944). | |
| const existingExport = fs.existsSync(outPath) ? fs.readFileSync(outPath, 'utf8') : null; | |
| fs.writeFileSync(outPath, serializeExport(exportData, existingExport)); | |
| console.log(`Wrote ${programs.length} programs to ${outPath}`); | |
| // Diff against the export currently on main (existingExport, read | |
| // above) so the PR summary and notifications reference only what THIS | |
| // run adds or changes, not every already-exported program (#1949 did | |
| // the same for the /lfx-url PR). A missing or malformed baseline | |
| // yields every program, matching a term's first export. | |
| let baselineExport = null; | |
| if (existingExport != null) { | |
| try { baselineExport = JSON.parse(existingExport); } catch { baselineExport = null; } | |
| } | |
| const { added, updated } = partitionExportChanges(baselineExport, exportData); | |
| // ── Generate README.md ── | |
| // The body (Table of Contents + Accepted Projects) is rendered by | |
| // lib/readme.js so the same renderer backs both the export and the | |
| // /lfx-url command (§4.3.5). The term frontmatter above the first | |
| // horizontal rule is preserved by buildReadme; only the body below | |
| // it is regenerated, so a re-export never wipes the term timeline. | |
| const md = renderAcceptedProgramsBody(programs); | |
| const readmePath = `${outDir}/README.md`; | |
| let existingReadme = null; | |
| if (fs.existsSync(readmePath)) { | |
| existingReadme = fs.readFileSync(readmePath, 'utf8'); | |
| } | |
| fs.writeFileSync(readmePath, buildReadme(existingReadme, md, readmeTitle)); | |
| const projectCount = new Set(programs.map(p => p.cncf_project)).size; | |
| console.log(`Wrote README.md with ${projectCount} projects, ${programs.length} programs`); | |
| // ── Generate tracking CSV (rendered by lib/csv.js) ── | |
| const csvPath = `${outDir}/lfx-tracking.csv`; | |
| fs.writeFileSync(csvPath, renderTrackingCsv(programs)); | |
| console.log(`Wrote ${programs.length} rows to ${csvPath}`); | |
| // ── Store path and issue numbers for later steps ── | |
| core.exportVariable('EXPORT_PATH', outPath); | |
| core.exportVariable('EXPORT_COUNT', programs.length.toString()); | |
| core.exportVariable('EXPORT_TERM', term); | |
| core.exportVariable('EXPORT_TERM_DIR', termDir); | |
| core.exportVariable('EXPORT_YEAR', year); | |
| // The Exported label and board sync act on the full exported set | |
| // (state, idempotent/guarded). The PR summary splits the changed set | |
| // into newly-added vs updated (#1992); the notification comment goes | |
| // ONLY to newly-added programs, so a re-export never re-lists the full | |
| // term nor re-pings a program whose data merely refreshed (e.g. a | |
| // project maturity change). | |
| core.exportVariable('EXPORTED_ISSUES', programs.map(p => p.issue_number).join(',')); | |
| core.exportVariable('NOTIFY_ISSUES', added.map(p => p.issue_number).join(',')); | |
| core.exportVariable('EXPORT_PROGRAM_LABEL', exportChangeLabel(added.length, updated.length)); | |
| // PR body: a "Newly added" section and/or an "Updated" section, each | |
| // shown only when non-empty, with a fallback line when neither is | |
| // present (removals or regenerated files only). Rendering + the | |
| // empty-section handling live in the tested lib helper. | |
| core.exportVariable('PR_BODY_ISSUES', renderExportChangeBody(added, updated)); | |
| - name: Check for changes | |
| id: check | |
| run: | | |
| if git diff --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then | |
| echo "changed=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "changed=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Create Pull Request | |
| if: steps.check.outputs.changed == 'true' | |
| uses: peter-evans/create-pull-request@v7 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| # signoff: true adds the DCO Signed-off-by; author/committer are the bot | |
| # so author == committer == sign-off, satisfying strict DCO checkers. | |
| signoff: true | |
| author: ${{ env.BOT_IDENTITY }} | |
| committer: ${{ env.BOT_IDENTITY }} | |
| commit-message: | | |
| chore: export ${{ env.EXPORT_COUNT }} LFX programs for ${{ env.EXPORT_TERM }} | |
| Auto-generated by lfx-export workflow. | |
| branch: automation/lfx-export-${{ env.EXPORT_YEAR }}-${{ env.EXPORT_TERM_DIR }} | |
| title: "chore: LFX export for ${{ env.EXPORT_TERM }} (${{ env.EXPORT_PROGRAM_LABEL }})" | |
| body: | | |
| Auto-generated export of the CNCF-approved LFX Mentorship programs | |
| for **${{ env.EXPORT_TERM }}**. | |
| ${{ env.PR_BODY_ISSUES }} | |
| **Export files:** | |
| - `${{ env.EXPORT_PATH }}`: structured JSON for LFX bulk import | |
| - `programs/lfx-mentorship/${{ env.EXPORT_YEAR }}/${{ env.EXPORT_TERM_DIR }}/README.md`: human-readable accepted-programs list | |
| - `programs/lfx-mentorship/${{ env.EXPORT_YEAR }}/${{ env.EXPORT_TERM_DIR }}/lfx-tracking.csv`: tracking spreadsheet with mentor details | |
| **Next steps:** | |
| 1. Review the JSON and README for accuracy. | |
| 2. Approve, then merge this PR. | |
| labels: administration | |
| - name: Notify exported issues | |
| if: steps.check.outputs.changed == 'true' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const issues = process.env.EXPORTED_ISSUES.split(',').filter(Boolean); | |
| const notify = new Set((process.env.NOTIFY_ISSUES || '').split(',').filter(Boolean).map(Number)); | |
| const term = process.env.EXPORT_TERM; | |
| const year = process.env.EXPORT_YEAR; | |
| const termDir = process.env.EXPORT_TERM_DIR; | |
| for (const num of issues) { | |
| const issue_number = parseInt(num); | |
| // Every exported issue carries the Exported label (idempotent). | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number, | |
| labels: ['Exported'], | |
| }); | |
| // Comment only on programs NEWLY added to the export this run, so a | |
| // re-export never re-posts "included in export" on an already-exported | |
| // proposal -- including one that only appears in this run because its | |
| // data was updated (e.g. a project maturity refresh). | |
| if (!notify.has(issue_number)) { | |
| console.log(`#${issue_number} not newly added in this export; no comment`); | |
| continue; | |
| } | |
| // Comment linking to the export | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number, | |
| body: `📦 This proposal has been included in the **${term}** export.\n\n` + | |
| `Export files: [\`programs/lfx-mentorship/${year}/${termDir}/\`](/${owner}/${repo}/tree/automation/lfx-export-${year}-${termDir}/programs/lfx-mentorship/${year}/${termDir}/)\n\n` + | |
| `The export PR is awaiting review. Once merged and posted to LFX, this issue will be updated again.`, | |
| }); | |
| console.log(`Notified issue #${issue_number}`); | |
| } | |
| - name: Sync board status for exported issues | |
| if: steps.check.outputs.changed == 'true' | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.PROJECT_TOKEN }} | |
| script: | | |
| // ── Resolve this repo's board from board.json (dev vs prod) ── | |
| // Only the projectId is stored per repo; the Status field ID and | |
| // option IDs are resolved live from the board so the fork (dev) and | |
| // cncf/mentoring (prod) run identical code. The board's Status | |
| // column names must match the lifecycle stages used by the workflows. | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const _lib = (m) => require(path.join(process.env.GITHUB_WORKSPACE || process.cwd(), 'programs/lfx-mentorship/automation/lib', m)); | |
| const { shouldSkipExport, readStatusWithRetry } = _lib('board.js'); | |
| const _board = JSON.parse(fs.readFileSync('programs/lfx-mentorship/automation/board.json', 'utf8')); | |
| const _envCfg = (_board.environments || {})[process.env.GITHUB_REPOSITORY]; | |
| if (!_envCfg || !_envCfg.projectId || String(_envCfg.projectId).startsWith('REPLACE_')) { | |
| core.warning(`No board configured for ${process.env.GITHUB_REPOSITORY} in board.json; skipping board sync.`); | |
| return; | |
| } | |
| const PROJECT_ID = _envCfg.projectId; | |
| const _statusField = (await github.graphql(` | |
| query($projectId: ID!) { | |
| node(id: $projectId) { ... on ProjectV2 { | |
| field(name: "Status") { ... on ProjectV2SingleSelectField { id options { id name } } } | |
| } } | |
| } | |
| `, { projectId: PROJECT_ID })).node.field; | |
| if (!_statusField) { | |
| core.warning(`Project ${PROJECT_ID} has no "Status" field; skipping board sync.`); | |
| return; | |
| } | |
| const STATUS_FIELD_ID = _statusField.id; | |
| const STATUS_OPTIONS = Object.fromEntries(_statusField.options.map(o => [o.name, o.id])); | |
| const EXPORTED_OPTION = STATUS_OPTIONS['Exported']; | |
| if (!EXPORTED_OPTION) { | |
| core.warning(`Board has no "Exported" status option; skipping board sync.`); | |
| return; | |
| } | |
| const issues = process.env.EXPORTED_ISSUES.split(',').filter(Boolean); | |
| for (const num of issues) { | |
| const { data: issue } = await github.rest.issues.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: parseInt(num), | |
| }); | |
| const addResult = await github.graphql(` | |
| mutation($projectId: ID!, $contentId: ID!) { | |
| addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { | |
| item { id } | |
| } | |
| } | |
| `, { projectId: PROJECT_ID, contentId: issue.node_id }); | |
| const itemId = addResult.addProjectV2ItemById.item.id; | |
| const { currentStatus, readFailed } = await readStatusWithRetry( | |
| () => github.graphql(` | |
| query($itemId: ID!) { | |
| node(id: $itemId) { ... on ProjectV2Item { | |
| fieldValueByName(name: "Status") { | |
| ... on ProjectV2ItemFieldSingleSelectValue { name } | |
| } | |
| } } | |
| } | |
| `, { itemId }), | |
| { onError: (attempt, e) => core.warning(`Could not read current board status for #${num} (attempt ${attempt}/2): ${e.message}`) }, | |
| ); | |
| // The export loop always targets "Exported", so skip whenever the | |
| // card is admin-owned (manual placement) or the status read failed | |
| // (fail closed, rather than risk pulling a card back). | |
| if (shouldSkipExport(currentStatus, readFailed)) { | |
| if (currentStatus) { | |
| console.log(`#${num} is in admin-owned column "${currentStatus}"; leaving placement to the administrator.`); | |
| } else { | |
| core.warning(`Status read failed for #${num} while about to set "Exported"; skipping to avoid clobbering a manual placement.`); | |
| } | |
| continue; | |
| } | |
| await github.graphql(` | |
| mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { | |
| updateProjectV2ItemFieldValue(input: { | |
| projectId: $projectId, itemId: $itemId, fieldId: $fieldId, | |
| value: { singleSelectOptionId: $optionId } | |
| }) { projectV2Item { id } } | |
| } | |
| `, { | |
| projectId: PROJECT_ID, | |
| itemId: itemId, | |
| fieldId: STATUS_FIELD_ID, | |
| optionId: EXPORTED_OPTION, | |
| }); | |
| console.log(`Board → Exported for #${num}`); | |
| } |