Skip to content

LFX Export

LFX Export #10

Workflow file for this run

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}`);
}