diff --git a/.github/workflows/create-theme-submission.yml b/.github/workflows/create-theme-submission.yml index 1411b66f..5374db07 100644 --- a/.github/workflows/create-theme-submission.yml +++ b/.github/workflows/create-theme-submission.yml @@ -61,18 +61,48 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Generate candidate entry + id: generate env: ISSUE_NUMBER: ${{ steps.issue.outputs.number }} ISSUE_TITLE: ${{ steps.issue.outputs.title }} ISSUE_AUTHOR: ${{ steps.issue.outputs.author }} ISSUE_BODY_FILE: issue-body.md GITHUB_TOKEN: ${{ github.token }} - run: node scripts/create-theme-submission-from-issue.mjs + shell: bash + run: | + set +e + node scripts/create-theme-submission-from-issue.mjs + status="$?" + set -e + + if [ "$status" -eq 78 ]; then + echo "valid=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ "$status" -ne 0 ]; then + exit "$status" + fi + + echo "valid=true" >> "$GITHUB_OUTPUT" + + - name: Comment on incomplete submission + if: ${{ steps.generate.outputs.valid == 'false' }} + env: + GH_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ steps.issue.outputs.number }} + shell: bash + run: | + gh issue comment "$ISSUE_NUMBER" --body-file theme-submission-error.md + cat theme-submission-error.md >> "$GITHUB_STEP_SUMMARY" - - run: npm test - - run: npm run build + - if: ${{ steps.generate.outputs.valid == 'true' }} + run: npm test + - if: ${{ steps.generate.outputs.valid == 'true' }} + run: npm run build - name: Open or update candidate PR + if: ${{ steps.generate.outputs.valid == 'true' }} env: GH_TOKEN: ${{ secrets.SUBMISSION_PR_TOKEN || github.token }} ISSUE_NUMBER: ${{ steps.issue.outputs.number }} diff --git a/MEMORY.md b/MEMORY.md index 465f0b7f..365650a0 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -44,6 +44,7 @@ This file stores durable project context so future conversations can resume work - Build workflow runs automatically on pushes to `main` that affect Astro site/build inputs and deploys `dist/` through GitHub Pages artifacts - PR validation runs `npm test` and `npm run build` for site-related changes - Theme submission automation uses GitHub Issue Forms plus `.github/workflows/create-theme-submission.yml`; it creates candidate PRs from complete submission issues without Decap or external auth hosting +- Theme submission automation was hardened on 2026-05-15 so user-correctable issue form errors exit as incomplete submissions, comment the required fix on the issue, and skip PR generation without hiding internal workflow failures. - Approved submission PRs are finalized by `.github/workflows/publish-approved-theme-submission.yml`, which sets `status: "published"` and assigns the next available low `catalogIndex`; maintainers still merge the PR explicitly - Merged submission PRs close their source issue through `.github/workflows/close-merged-theme-submission.yml`; new generated PR bodies also include `Closes #` - The close-merged submission workflow became idempotent on 2026-05-03 and was hardened on 2026-05-04: it now exits successfully when the source issue was already closed by GitHub's `Closes #` automation, accepting both uppercase and lowercase issue state values from `gh`. diff --git a/scripts/create-theme-submission-from-issue.mjs b/scripts/create-theme-submission-from-issue.mjs index b8005c9f..717bc559 100644 --- a/scripts/create-theme-submission-from-issue.mjs +++ b/scripts/create-theme-submission-from-issue.mjs @@ -22,60 +22,97 @@ if (!issueAuthor) { throw new Error('ISSUE_AUTHOR must be set.') } -const fields = parseIssueForm(issueBody) -const title = requiredField(fields, 'Theme title') -const repository = requiredUrl(requiredField(fields, 'Original repository'), 'Original repository') -const homepage = optionalUrl(optionalField(fields, 'Homepage'), 'Homepage') -const description = requiredField(fields, 'Short description') -const screenshotUrls = extractScreenshotUrls(requiredField(fields, 'Screenshot URLs')) -const tags = normalizeTags(requiredField(fields, 'Tags')) -const submitterRole = normalizeSubmitterRole(requiredField(fields, 'Your relationship to the theme')) -const notes = optionalField(fields, 'Notes for reviewers') -const slug = uniqueSlug(slugify(title)) -const submittedBy = issueAuthor.length >= 2 ? issueAuthor : null -const repositoryStats = await fetchRepositoryStats(repository) - -if (title.length < 2 || title.length > 80) { - throw new Error('Theme title must be between 2 and 80 characters.') +class SubmissionInputError extends Error { + constructor(message) { + super(message) + this.name = 'SubmissionInputError' + } } -if (description.length < 12 || description.length > 420) { - throw new Error('Short description must be between 12 and 420 characters.') +try { + await createThemeSubmission() +} catch (error) { + if (error instanceof SubmissionInputError) { + writeSubmissionError(error.message) + console.error(error.message) + process.exit(78) + } + + throw error } -fs.mkdirSync(imagesDir, { recursive: true }) +async function createThemeSubmission() { + const fields = parseIssueForm(issueBody) + const title = requiredField(fields, 'Theme title') + const repository = requiredUrl(requiredField(fields, 'Original repository'), 'Original repository') + const homepage = optionalUrl(optionalField(fields, 'Homepage'), 'Homepage') + const description = requiredField(fields, 'Short description') + const screenshotUrls = extractScreenshotUrls(requiredField(fields, 'Screenshot URLs')) + const tags = normalizeTags(requiredField(fields, 'Tags')) + const submitterRole = normalizeSubmitterRole(requiredField(fields, 'Your relationship to the theme')) + const notes = optionalField(fields, 'Notes for reviewers') + const slug = uniqueSlug(slugify(title)) + const submittedBy = issueAuthor.length >= 2 ? issueAuthor : null + const repositoryStats = await fetchRepositoryStats(repository) + + if (title.length < 2 || title.length > 80) { + throw inputError('Theme title must be between 2 and 80 characters.') + } -const screenshots = [] -for (const [index, url] of screenshotUrls.entries()) { - const image = await downloadImage(url) - const filename = `${slug}-${index + 1}.${image.extension}` - const destination = path.join(imagesDir, filename) + if (description.length < 12 || description.length > 420) { + throw inputError('Short description must be between 12 and 420 characters.') + } - fs.writeFileSync(destination, image.buffer) - screenshots.push({ - src: `/assets/img/themes/${filename}`, - alt: `${title} screenshot ${index + 1}` - }) + fs.mkdirSync(imagesDir, { recursive: true }) + + const screenshots = [] + for (const [index, url] of screenshotUrls.entries()) { + const image = await downloadImage(url) + const filename = `${slug}-${index + 1}.${image.extension}` + const destination = path.join(imagesDir, filename) + + fs.writeFileSync(destination, image.buffer) + screenshots.push({ + src: `/assets/img/themes/${filename}`, + alt: `${title} screenshot ${index + 1}` + }) + } + + const theme = { + title, + slug, + description, + repository, + ...(homepage ? { homepage } : {}), + screenshots, + tags, + submitterRole, + status: 'candidate', + catalogIndex: candidateCatalogIndex(), + ...(submittedBy ? { submittedBy } : {}), + stats: repositoryStats + } + + const themePath = path.join(themesDir, `${slug}.json`) + fs.writeFileSync(themePath, `${JSON.stringify(theme, null, 2)}\n`) + fs.writeFileSync(path.join(root, 'theme-submission-pr.md'), prBody({ theme, notes, issueNumber, issueAuthor, screenshotUrls })) } -const theme = { - title, - slug, - description, - repository, - ...(homepage ? { homepage } : {}), - screenshots, - tags, - submitterRole, - status: 'candidate', - catalogIndex: candidateCatalogIndex(), - ...(submittedBy ? { submittedBy } : {}), - stats: repositoryStats +function inputError(message) { + return new SubmissionInputError(message) } -const themePath = path.join(themesDir, `${slug}.json`) -fs.writeFileSync(themePath, `${JSON.stringify(theme, null, 2)}\n`) -fs.writeFileSync(path.join(root, 'theme-submission-pr.md'), prBody({ theme, notes, issueNumber, issueAuthor, screenshotUrls })) +function writeSubmissionError(message) { + const body = ` +Thanks for submitting a theme. I could not generate the candidate pull request yet because the submission form needs one fix: + +> ${message} + +Please edit this issue with the missing or corrected information. The automation will try again after the issue is updated. +` + + fs.writeFileSync(path.join(root, 'theme-submission-error.md'), body) +} function parseIssueForm(body) { const headings = [...body.matchAll(/^###\s+(.+?)\s*$/gm)] @@ -97,7 +134,7 @@ function requiredField(fields, label) { const value = optionalField(fields, label) if (!value) { - throw new Error(`Missing required issue field: ${label}`) + throw inputError(`Missing required issue field: ${label}`) } return value @@ -111,7 +148,7 @@ function requiredUrl(value, label) { const url = parseUrl(value, label) if (!['http:', 'https:'].includes(url.protocol)) { - throw new Error(`${label} must be an HTTP or HTTPS URL.`) + throw inputError(`${label} must be an HTTP or HTTPS URL.`) } return url.toString() @@ -127,7 +164,7 @@ function parseUrl(value, label) { try { return new URL(value.trim()) } catch { - throw new Error(`${label} must be a valid URL.`) + throw inputError(`${label} must be a valid URL.`) } } @@ -147,11 +184,11 @@ function extractScreenshotUrls(value) { const screenshotUrls = [...urls].filter(Boolean) if (screenshotUrls.length === 0) { - throw new Error('At least one screenshot URL or attachment is required.') + throw inputError('At least one screenshot URL or attachment is required.') } if (screenshotUrls.length > 6) { - throw new Error('Use 6 screenshots or fewer for a single submission.') + throw inputError('Use 6 screenshots or fewer for a single submission.') } return screenshotUrls @@ -173,16 +210,16 @@ function normalizeTags(value) { .sort((a, b) => a.localeCompare(b)) if (tags.length === 0) { - throw new Error('At least one tag is required.') + throw inputError('At least one tag is required.') } if (tags.length > 24) { - throw new Error('Use 24 tags or fewer.') + throw inputError('Use 24 tags or fewer.') } for (const tag of tags) { if (!/^[a-z0-9][a-z0-9 .:+/_-]*[a-z0-9]$|^[a-z0-9]$/.test(tag)) { - throw new Error(`Invalid tag "${tag}". Tags must be lowercase and use letters, numbers, spaces, dots, colons, plus signs, slashes, underscores, or hyphens.`) + throw inputError(`Invalid tag "${tag}". Tags must be lowercase and use letters, numbers, spaces, dots, colons, plus signs, slashes, underscores, or hyphens.`) } } @@ -201,7 +238,7 @@ function normalizeSubmitterRole(value) { const role = roles.get(normalized) if (!role) { - throw new Error(`Unsupported submitter role: ${value}`) + throw inputError(`Unsupported submitter role: ${value}`) } return role @@ -216,7 +253,7 @@ function slugify(value) { .replace(/^-+|-+$/g, '') if (!slug) { - throw new Error('Theme title could not be converted into a valid slug.') + throw inputError('Theme title could not be converted into a valid slug.') } return slug @@ -236,7 +273,7 @@ function uniqueSlug(baseSlug) { return issueSlug } - throw new Error(`A theme entry already exists for slug "${baseSlug}" and issue slug "${issueSlug}".`) + throw inputError(`A theme entry already exists for slug "${baseSlug}" and issue slug "${issueSlug}".`) } function candidateCatalogIndex() { @@ -247,11 +284,11 @@ async function downloadImage(value) { const url = parseUrl(value, 'Screenshot URL') if (url.protocol !== 'https:') { - throw new Error(`Screenshot URLs must use HTTPS: ${value}`) + throw inputError(`Screenshot URLs must use HTTPS: ${value}`) } if (isBlockedHost(url.hostname)) { - throw new Error(`Screenshot URL uses a blocked host: ${url.hostname}`) + throw inputError(`Screenshot URL uses a blocked host: ${url.hostname}`) } const response = await fetch(url, { @@ -262,21 +299,21 @@ async function downloadImage(value) { }) if (!response.ok) { - throw new Error(`Could not download screenshot ${value}: ${response.status} ${response.statusText}`) + throw inputError(`Could not download screenshot ${value}: ${response.status} ${response.statusText}`) } const contentType = response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() const extension = extensionFor(contentType, url.pathname) if (!extension) { - throw new Error(`Screenshot must be a PNG, JPG, WEBP, or GIF image: ${value}`) + throw inputError(`Screenshot must be a PNG, JPG, WEBP, or GIF image: ${value}`) } const buffer = Buffer.from(await response.arrayBuffer()) const maxBytes = 8 * 1024 * 1024 if (buffer.length > maxBytes) { - throw new Error(`Screenshot is larger than 8 MB: ${value}`) + throw inputError(`Screenshot is larger than 8 MB: ${value}`) } return { buffer, extension }