These GitHub Actions workflows connect a repository to a Discourse forum. Each issue and pull request gets its own forum topic, and comments travel both ways.
The issues here are a live demo. When you open one, a topic opens on the forum this repository is connected to.
From GitHub to Discourse:
| On GitHub | On the Forum | Workflow |
|---|---|---|
| Issue opened | New topic, and the issue gets a comment with its link | issue-to-discourse.yml |
| Pull request opened, reopened or ready for review | New topic if it has none yet, and the pull request gets a comment with its link | pr-to-discourse.yml |
| Comment on an issue or pull request conversation | Reply in the topic | discourse-comment-to-topic.yml |
| Issue closed or reopened, pull request closed, merged or reopened | Reply in the topic, then the topic closes or reopens | discourse-state.yml |
| Pull request review: approved, changes requested or commented | Reply in the topic with the review summary, within 15 minutes | discourse-topic-to-issue.yml |
From Discourse to GitHub:
| On the Forum | On GitHub | Workflow |
|---|---|---|
| Reply in a linked topic | Comment on its issue or pull request | discourse-topic-to-issue.yml |
How it behaves:
-
Every forum post links back to GitHub with an inline "View on GitHub" link, not a bare URL, so Discourse shows no card repeating the GitHub text.
-
Each topic has the
external_idgh-<key>-issue-<n>orgh-<key>-pr-<n>. The key is the first 12 hex characters of the SHA-256 ofowner/name, so repositories that share a forum get different IDs. A run that repeats links the existing topic instead of opening a second one. -
Draft pull requests wait until they are ready for review. Issues and pull requests opened by bots get no topic.
-
When an issue is completed or a pull request is merged, its close or merge reply is marked as the solution, unless the topic already has an accepted answer. A reopen unaccepts only those close or merge replies.
-
Discourse cannot call GitHub, so
discourse-topic-to-issue.ymlchecks the forum every 15 minutes. It reads only topics bumped since its last successful run on the same branch started, less 15 minutes, and never more than the last 48 hours. On the first run, or when that run cannot be found, it reads 48 hours. -
All runs for one issue or pull request wait in a single queue (
concurrency.queue: max), so the topic is normally there before its comments, close or reopen arrive. GitHub's workflow syntax docs say queued runs "are processed in first-in-first-out (FIFO) order according to the time each one started waiting on the concurrency group" and that "ordering is not guaranteed".
For a new repository, select Use this template on GitHub.
For an existing repository, copy these files to the same paths:
- the five workflows named in the tables above
scripts/discourse-sync.mjs.nvmrc, if your repository does not have one yet
The script uses only Node built-ins, so it needs no npm install. In a copy
made with the template, also remove the issue form and the live demo note at
the top of this file.
Until the DISCOURSE_URL and DISCOURSE_CATEGORY_ID variables are set, every
sync job is skipped, so a fork or copy posts nothing. CI still runs.
-
Create a user for the sync, for example
github-bot. This user posts every topic and reply. -
Let it close and reopen topics. It needs to be staff, trust level 4 or a moderator of the category. Without these rights, the close or reopen fails with an error that names them. A close still posts its reply first, but a reopen posts nothing. Staff is the best choice: most Discourse rate limits, such as the wait between new topics, do not apply to staff.
-
Turn on Solved for the category so a reply can be marked as the solution. Any of these works: the Support category type, the site setting
allow_solved_on_all_topicsor, in older versions, the category setting "Allow topic owner and staff to mark a reply as the solution". Without Solved, topics still close. -
At
/admin/api/keys, create a granular key for that single user with these scopes:topics:write,read,read_lists,statussolved:answer
If a scope is missing, the job fails with an error that names the scope it needs.
-
Find the category ID. It is the number at the end of the category URL, such as
5inhttps://forum.example.com/c/github/5.
The sync user opens each topic, so it can mark a solution while the topic is open.
In Settings > Secrets and variables > Actions, add:
| Name | Kind | Value |
|---|---|---|
DISCOURSE_URL |
Variable | Forum base URL over https, such as https://forum.example.com |
DISCOURSE_CATEGORY_ID |
Variable | Category ID, such as 5 |
DISCOURSE_API_KEY |
Secret | The granular API key |
DISCOURSE_API_USERNAME |
Secret | The sync user's username |
Or with the GitHub CLI:
gh variable set DISCOURSE_URL --body https://forum.example.com
gh variable set DISCOURSE_CATEGORY_ID --body 5
gh secret set DISCOURSE_API_KEY
gh secret set DISCOURSE_API_USERNAMEDISCOURSE_URL must use https. Plain http works only for a forum on the same
machine (localhost, 127.0.0.1 or ::1), which the tests use.
The sync user needs a username in plain ASCII, without spaces, because HTTP
headers carry it.
gh secret set asks for the value, so it stays out of your shell history.
- Anyone can open an issue, a pull request from a fork or a comment on a public repository. That means anyone can open a topic or post to the forum.
- Forum replies in the synced category become public on GitHub, even when the forum needs a login.
- Copied text, in both directions, is changed so these forms do not notify
anyone or link an issue:
-
@name: a zero-width space after every@ -
#1,owner/repo#1: a zero-width space after every#before a digit -
GH-1: a zero-width space afterGH-before a digit, in any case -
issues/1,pull/1,pulls/1,discussions/1: a zero-width space after the slash -
github.com: every occurrence, in any case, becomesredirect.github.com, soapi.github.combecomesapi.redirect.github.com. GitHub documents that aredirect.github.comlink adds no backlink: -
[quote="name"]andusername:name: a zero-width space after[quoteand before the colon inusername: -
discourse-post:: a zero-width space before the colon, so copied text cannot pose as a copy marker -
HTML entities such as
@: the&becomes&
-
- Forms not in this list are copied as written. A forum link in a GitHub comment stays a link on the forum, and Discourse may notify the author of the linked post.
- The forum username at the top of a reply copied to GitHub gets the same changes.
- A backslash escape such as
github\.com,GH\-1orissues\/1gets the same change as the unescaped form. - These changes apply everywhere in the text, including email addresses,
links and code. A copied email address, a link with
@, a#and a number or an issue path, or a command with any of them needs editing before use. - Edits and deletions are not copied.
- Re-running a close or reopen event from before forum posts linked back inline posts its reply again, because the reply text changed.
- Review summaries are copied by the 15-minute schedule, so they reach the forum up to 15 minutes later. An approval without a summary posts one line. Review comments on code lines are not copied, and neither is a Commented review that holds only those. A review dismissed after it was copied stays on the forum. A review on a closed or merged pull request is copied into its closed topic. Only the 300 pull requests updated most recently since the cutoff are read for reviews; a run with more prints a warning.
- Comments made before a pull request has its topic, such as while it is a first draft, are not copied.
- Text that would make a forum post longer than 32,000 characters is shortened, with a note that the full text is on GitHub. A forum reply longer than a GitHub comment, 65,536 characters, is shortened with a note that the full text is on the forum.
- A topic syncs only while its title starts with
GitHub issue #<n>:orGitHub pull request #<n>:and it stays in the synced category. Retitling or moving it stops forum replies from reaching GitHub. - If you change
DISCOURSE_API_USERNAME, the old user's replies stay where they are and are not copied to GitHub again. A reopen still unaccepts the old user's close or merge reply. A repeated run reuses only a reply the current user posted, so a re-run after the change posts its reply again. - In a public repository, GitHub turns off the 15-minute schedule after 60 days without repository activity. You can turn it back on in the Actions tab. Each run reads only topics active in the last 48 hours, so a reply posted while the schedule was off for longer is copied once its topic is active again.
- One issue or pull request can have up to 100 runs waiting, which is GitHub's limit. A run beyond that is canceled and not retried.
- Discourse allows 60 admin API requests a minute for the whole site by
default, shared by every key and staff included
(
DISCOURSE_MAX_ADMIN_API_REQS_PER_MINUTE).discourse-topic-to-issue.ymlwaits 1.5 seconds between forum requests, about 40 a minute, to leave room for the other workflows. A forum request that hits the limit waits up to a minute, as Discourse asks, within the run's time limit; a run that still cannot finish fails and its event can be run again. - The other workflows are not paced. A bulk action, such as closing 30 issues at once, starts that many runs in parallel and can pass the forum's limit, so some of them may fail and need a re-run.
- Each topic read costs about 3.3 seconds with this pacing, and each copied
reply or review adds time, so one run of
discourse-topic-to-issue.ymlhandles up to about 150 topics bumped and pull requests with new reviews since the last successful run. With more, runs fail at their time limit and read the same topics again until activity drops; replies older than 48 hours by then are not copied. - The cutoff moves only after a run on the same branch whose job ran and succeeded. One topic or pull request that keeps failing makes every run fail, so the cutoff stops moving and reads widen to 48 hours until it is fixed.
- After a long pause, such as a forum outage, the backlog of replies can take several runs to copy, and those runs may fail until it catches up. Each run copies only replies not yet on GitHub, so none is copied twice.
pr-to-discourse.ymlanddiscourse-state.ymlrun onpull_request_target, so a pull request from a fork can reach the secrets. They check out only the default branch and never run the pull request's code. Tests check everypull_request_targetworkflow for a checkout of another ref and for a step that fetches the pull request.- Issue, pull request and comment text reaches the script through the event
file, never through
${{ }}inside a shell script. - Review summaries are read through the API by the scheduled workflow. A
pull_request_reviewevent from a fork runs without secrets and with a read-only token, andworkflow_runwould add another trigger that runs with secrets, so neither is used. - Requests do not follow a redirect to another host, so the API key goes only
to
DISCOURSE_URL. - Every action is pinned to a commit SHA. A test checks this and the rule
above about
${{ }}.
npm ci
npm run lint # Prettier, and TypeScript over the tests
npm test # Node's built-in test runnerMIT license:
AI disclosure: