Skip to main content

GitHub Issue + Action submission workflow

An alternative to the built-in submission form is a fully automated GitHub-native approach: contributors fill in a GitHub Issue template, and a GitHub Actions workflow parses the issue, generates the YAML file, commits it to a new branch, and opens a draft pull request, all without leaving GitHub.

This is the pattern used by the Docusaurus Community website.

How it works

  1. A contributor opens a new issue using your issue template. GitHub renders it as a structured form with dropdowns, checkboxes, and text fields.
  2. When the issue is opened, the GitHub Action fires. It parses the issue body, validates required fields, generates the YAML, creates a branch, commits the file, and opens a draft PR and then posts the PR link back as a comment on the issue.
  3. A maintainer reviews and merges the PR.
Label Creation

The workflow won't create the label automatically. You must create the label in your repository settings to match the labels entry in your issue template.

Schema Validation

If you have a schemaUrl defined in your plugin configuration, the Action will include it in the generated YAML file. This allows the yaml-language-server to validate the YAML in the PR and provide inline feedback to maintainers.

Issue template

Create .github/ISSUE_TEMPLATE/add-item.yml in your repository. Adapt the tags checkboxes and status dropdown options to match the tags and statuses defined in your plugin configuration.

.github/ISSUE_TEMPLATE/add-item.yml
name: Add an Item
description: Submit a new item to the showcase.
title: "[Submission] "
labels:
- showcase-submission
body:
- type: markdown
attributes:
value: |
Fill in the fields below. A bot will generate your YAML file and open a draft PR automatically.

- type: input
id: name
attributes:
label: Name
description: Display name shown in the showcase.
placeholder: "My Plugin"
validations:
required: true

- type: input
id: id
attributes:
label: ID
description: Unique identifier — lowercase letters, numbers, and hyphens only.
placeholder: "author.my-plugin"
validations:
required: true

- type: textarea
id: description
attributes:
label: Description
description: One sentence describing what this item does.
placeholder: "A plugin that does X."
validations:
required: true

- type: input
id: website
attributes:
label: Website URL
description: Primary URL linked from the card title.
placeholder: "https://example.com"
validations:
required: true

- type: input
id: source
attributes:
label: Source code URL
placeholder: "https://github.com/..."

- type: input
id: author
attributes:
label: Author
placeholder: "yourname"

- type: input
id: preview
attributes:
label: Preview image URL
placeholder: "https://example.com/preview.png"

# Adapt the options below to match the `statuses` defined in your plugin config.
- type: dropdown
id: status
attributes:
label: Status
options:
- maintained
- unmaintained
- unknown
validations:
required: true

- type: input
id: minimumVersion
attributes:
label: Minimum version
placeholder: "3.0.0"

# Adapt the labels below to match the keys defined in your plugin's `tags` config.
- type: checkboxes
id: tags
attributes:
label: Tags
options:
- label: search
- label: api
- label: utility
- label: content
- label: theme

- type: textarea
id: npmPackages
attributes:
label: npm package name(s)
description: One package name per line. Leave blank if not applicable.
placeholder: "my-npm-package"

GitHub Action

Create .github/workflows/create-submission-pr.yml. Update the three variables at the top of the script block to match your repository's layout:

  • DATA_DIR — path to your showcase data directory (matches dataDir in your plugin config).
  • LABEL — the issue label that triggers the workflow (must match the labels entry in your issue template).
  • SCHEMA_URL — the yaml-language-server schema URL (matches schemaUrl in your plugin config, or omit the line if you haven't set one).
.github/workflows/create-submission-pr.yml
name: Create Showcase PR from Issue

on:
issues:
types: [opened]

permissions:
contents: write
pull-requests: write
issues: write

jobs:
create-pr:
if: contains(github.event.issue.labels.*.name, 'showcase-submission')
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
// ── Configure these for your repository ──────────────────────────
const DATA_DIR = 'data/plugins';
const LABEL = 'showcase-submission';
const SCHEMA_URL = 'https://cdn.jsdelivr.net/npm/@homotechsual/docusaurus-plugin-showcase/schema/plugins-preset/1.0.0.json';
// ─────────────────────────────────────────────────────────────────

const body = context.payload.issue.body ?? '';
const issueNumber = context.payload.issue.number;
const issueUrl = context.payload.issue.html_url;

function parseField(body, heading) {
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`### ${escaped}\\r?\\n\\r?\\n([\\s\\S]*?)(?=\\r?\\n### |$)`);
const match = body.match(regex);
if (!match) return null;
const val = match[1].trim();
return val === '_No response_' || val === '' ? null : val;
}

function parseCheckboxes(body, heading) {
const section = parseField(body, heading);
if (!section) return [];
return section.split('\n')
.filter(line => /^- \[x\]/i.test(line.trim()))
.map(line => line.replace(/^- \[x\] /i, '').trim());
}

const name = parseField(body, 'Name');
const id = parseField(body, 'ID');
const description = parseField(body, 'Description');
const website = parseField(body, 'Website URL');
const source = parseField(body, 'Source code URL');
const author = parseField(body, 'Author');
const preview = parseField(body, 'Preview image URL');
const status = parseField(body, 'Status');
const minimumVersion = parseField(body, 'Minimum version');
const npmPackagesRaw = parseField(body, 'npm package name(s)');
const tags = parseCheckboxes(body, 'Tags');

// Validate required fields
const errors = [];
if (!name) errors.push('Name is required');
if (!id) errors.push('ID is required');
if (!description) errors.push('Description is required');
if (!website) errors.push('Website URL is required');
if (!status) errors.push('Status is required');

if (errors.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: [
'Thank you for your submission! Some required fields are missing or invalid:',
'',
...errors.map(e => `- ${e}`),
'',
'Please close this issue and open a new one with all fields filled in.',
].join('\n'),
});
return;
}

const npmPackages = npmPackagesRaw
? npmPackagesRaw.split('\n').map(l => l.trim()).filter(Boolean)
: [];

const filename = `${id}.yaml`;
const branch = `showcase/${id}`;

// Check for duplicate IDs
try {
await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: `${DATA_DIR}/${filename}`,
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: `An item with ID \`${id}\` already exists. Please choose a unique ID, then close this issue and open a new one.`,
});
return;
} catch (e) {
// File doesn't exist — expected path
}

// Build YAML
const yamlLines = [];
if (SCHEMA_URL) yamlLines.push(`# yaml-language-server: $schema=${SCHEMA_URL}`);
yamlLines.push(`id: ${id}`);
yamlLines.push(`name: "${name.replace(/"/g, '\\"')}"`);
yamlLines.push(`description: "${description.replace(/"/g, '\\"')}"`);
yamlLines.push(`preview: ${preview || 'null'}`);
yamlLines.push(`website: ${website}`);
yamlLines.push(`source: ${source || 'null'}`);
yamlLines.push(`author: ${author || 'null'}`);

if (tags.length > 0) {
yamlLines.push('tags:');
tags.forEach(t => yamlLines.push(` - ${t}`));
} else {
yamlLines.push('tags: []');
}

yamlLines.push(`minimumVersion: ${minimumVersion || 'null'}`);
yamlLines.push(`status: ${status}`);

if (npmPackages.length > 0) {
yamlLines.push('npmPackages:');
npmPackages.forEach(p => yamlLines.push(` - ${p}`));
} else {
yamlLines.push('npmPackages: []');
}

const yaml = yamlLines.join('\n') + '\n';

// Create branch from main
const mainRef = await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: 'heads/main',
});
const baseSha = mainRef.data.object.sha;

let actualBranch = branch;
try {
await github.rest.git.createRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `refs/heads/${branch}`,
sha: baseSha,
});
} catch (e) {
// Branch already exists — append issue number to make it unique
actualBranch = `${branch}-${issueNumber}`;
await github.rest.git.createRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `refs/heads/${actualBranch}`,
sha: baseSha,
});
}

// Commit the YAML file
await github.rest.repos.createOrUpdateFileContents({
owner: context.repo.owner,
repo: context.repo.repo,
path: `${DATA_DIR}/${filename}`,
message: `chore: add ${id} from issue #${issueNumber}`,
content: Buffer.from(yaml).toString('base64'),
branch: actualBranch,
});

// Open a draft PR
const pr = await github.rest.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Add item: ${name}`,
head: actualBranch,
base: 'main',
body: [
`Adds \`${id}\` to the showcase.`,
'',
`Generated automatically from issue ${issueUrl}.`,
'',
`Closes #${issueNumber}`,
].join('\n'),
draft: true,
});

// Reply on the issue
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: [
`Thank you for your submission! A draft PR has been created: ${pr.data.html_url}`,
'',
'A maintainer will review it shortly.',
].join('\n'),
});

Comparison with the built-in form

Built-in form (submitFormPath)GitHub Issue + Action
Where it livesYour Docusaurus siteGitHub
Contributor needs a GitHub accountNoYes
PR created automaticallyNo, contributor opens PR manuallyYes, bot opens a draft PR
Maintainer review stepContributor submits PR; maintainer mergesBot opens draft PR; maintainer reviews and merges
Requires GitHub ActionsNoYes
Works without a public GitHub repoYesNo
Tag / status options are validatedClient-side (form UI)Server-side (Action script)

Both approaches produce identical YAML output and are compatible with the same plugin configuration. You can offer both simultaneously, for example, link submitFormPath for contributors who prefer a guided form, and maintain the issue template for those who prefer staying in GitHub.