GitHub Actions for publishing markdown to Confluence with markfluence.
Two actions, because they answer different questions:
mozilla/markfluence-action/setupinstalls the markfluence CLI and puts it onPATH, so a workflow can run any of its commands.mozilla/markfluence-actionis the opinionated publish action: it narrows a glob to the files git says actually changed, and publishes those.
This split facilitates a convenient publish action step and also allows for other use cases.
| runner | supported |
|---|---|
ubuntu-latest |
✅ |
ubuntu-24.04-arm |
✅ |
macos-latest |
✅ |
macos-13 (Intel) |
❌ |
windows-latest |
❌ |
markfluence publishes no Windows build, and no Intel macOS build since macOS 26 Tahoe became Apple's last Intel release. Both fail with a named error rather than a download 404, so the message says what to use instead.
markfluence reads CONFLUENCE_URL (secret), CONFLUENCE_USERNAME (secret) and
CONFLUENCE_TOKEN (secret) from the environment, plus CONFLUENCE_CLOUD_ID
(not a secret) for a scoped token.
Example:
- uses: mozilla/markfluence-action/setup@v1
- run: markfluence space-info ENG
env:
CONFLUENCE_URL: ${{ secrets.CONFLUENCE_URL }}
CONFLUENCE_USERNAME: ${{ secrets.CONFLUENCE_USERNAME }}
CONFLUENCE_TOKEN: ${{ secrets.CONFLUENCE_TOKEN }}
# A variable, not a secret: the cloud ID is not sensitive.
CONFLUENCE_CLOUD_ID: ${{ vars.CONFLUENCE_CLOUD_ID }}Store all three as encrypted secrets — never commit them. The cloud ID is not sensitive, so make it a repository variable instead.
Use a service account API token, not your own personal API token. Published pages are authored by whoever owns the token, so a personal one puts your name on every page a workflow touches. Additionally, publishing breaks the day that person rotates their token or leaves.
A service account means a scoped token, which is why CONFLUENCE_CLOUD_ID
is needed — a scoped token is rejected with a 401 against your site domain, so
markfluence has to use Atlassian's api.atlassian.com gateway, and the cloud
ID is required there.
See also:
- Scoped tokens and service accounts — the scope list, how to find your cloud ID, and how to tell a missing scope (401) from a missing permission (403)
A publishing workflow only makes sense when the repository is authoritative and the Confluence page is a published copy of it.
If Confluence is the source of truth, do not run this. markfluence has no way to discover changes made in the Confluence UI and no mechanism for reconciling them.
If the repository is the source of truth, it's best to include a note at the top of the page and a link to the source file so users know where to make changes in a way that doesn't get clobbered on the next publish.
> [!NOTE]
> This page is published from [docs/deploy-runbook.md](https://github.com/ORG/REPO/blob/main/docs/deploy-runbook.md).
> Edits made here are overwritten on the next push. Open a pull request instead.Additionally, you can restrict permissions on the Confluence page to just the publishing account.
This publishes every markdown file that changed, matches the files pathspec,
and names a Confluence page — via a page_id in its frontmatter or an
entry in markfluence.yaml. You need that id before a workflow can publish
anything, which means creating each page once by hand and committing the id;
see What it does not do.
A file that names no page is skipped, not failed, so a repository that has markdown files that aren't intended to be published to Confluence does not cause errors.
name: Publish docs to Confluence
on:
push:
branches: [main]
paths: ['docs/**.md']
# Don't let two publishes race on the same pages.
concurrency:
group: confluence-publish
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
with:
fetch-depth: 0 # both ends of the push range have to be present
- uses: mozilla/markfluence-action@v1
with:
files: 'docs/**/*.md'
env:
CONFLUENCE_URL: ${{ secrets.CONFLUENCE_URL }}
CONFLUENCE_USERNAME: ${{ secrets.CONFLUENCE_USERNAME }}
CONFLUENCE_TOKEN: ${{ secrets.CONFLUENCE_TOKEN }}
CONFLUENCE_CLOUD_ID: ${{ vars.CONFLUENCE_CLOUD_ID }}fetch-depth: 0 is required and the action cannot set it for you. Without
it the push range cannot be resolved; the action detects the shallow clone and
says so by name rather than failing on a confusing bad object.
| input | default | description |
|---|---|---|
files |
docs/**/*.md |
Which markdown to publish, as one or more git pathspecs. See How files works. |
changed-only |
true |
Publish only what changed. Leave it on — see below. Works on push and pull_request; any other event has no commit range and the run fails rather than publishing everything. |
since |
(none) | Base ref to diff against, overriding the event's. Required on an event with no range, such as workflow_dispatch or schedule. |
dry-run |
false |
Preview without writing to Confluence. |
debug |
false |
Log every retry decision with the rate-limit headers. |
version |
latest |
markfluence release to install. |
| output | description |
|---|---|
count |
Files selected for publishing. |
published |
Pages published. |
skipped |
Files skipped because nothing claims them. |
failed |
Files that failed. |
results-json |
Path to markfluence's --json envelope, for a later step. Empty when nothing ran. |
files takes one or more git pathspecs, separated by spaces. They are not
shell globs, but they behave like them: each pattern gets git's :(glob)
magic, so ** spans zero or more directories and * stops at /.
| value | matches |
|---|---|
docs/**/*.md |
every .md under docs/, at any depth including the top level |
docs/*.md |
only the top level of docs/ |
**/*.md |
every .md in the repository |
docs/**/*.md runbooks/**/*.md |
both trees |
docs/a.md docs/b.md |
exactly those two files, and nothing else |
docs/**/*.md :!docs/private/** |
the first, minus the second |
A pattern starting with : is passed through untouched, which is what makes
that last row work: :! is git's exclusion magic, and any other pathspec
magic (:(icase), :(top)) works the same way.
Three limits worth knowing:
- A pattern cannot contain a space, because the input is split on
whitespace.
'my docs/*.md'becomes two patterns and matches nothing useful, and the multi-line form does not change that — splitting happens within a line as well as between lines. Matched paths may contain spaces (docs/release notes.mdpublishes fine); it is only the pattern that cannot. - Value must be a scalar, because GitHub requires every
with:value to be a scalar, sofiles:followed by- docs/a.mdis rejected before this action sees it. - Only tracked files match. git does not see an untracked file, so a brand-new markdown file that has not been committed is not published. That is never an issue in CI, where the checkout is clean, but it will surprise you running the action locally.
Patterns can be on one line:
with:
files: docs/**/*.md runbooks/**/*.md :!docs/private/**Or multiple lines with |:
with:
files: |
docs/**/*.md
runbooks/**/*.md
:!docs/private/**Paths are relative to the repository root.
References:
paths: on the trigger decides whether the job runs but doesn't affect the
files that get published.
files specifies all the possible files in the repository that could be
published after a merge.
changed-only ensures that only the files listed in files that actually
changed are published. Otherwise one typo fix republishes the whole tree, which
costs more than tidiness:
- Confluence emails every watcher on update. Republishing 200 pages mails everyone watching any of them, for a change to one. This is the cost that gets a publishing bot switched off.
- Page history stops being useful. A run of identical new versions across the tree makes "who changed this, and why" unanswerable in the UI.
- It is N times the API calls, against a rate limit shared with everyone else on the instance, and a correspondingly slow job.
Turn it off only if you mean it.
An event with no commit range fails rather than guessing. A schedule or
a workflow_dispatch has no base to diff against, and treating that as
"publish everything" would silently produce exactly the mass notification
described above. Pass since: on those events, or set changed-only: false
if you really do mean the whole tree.
CI is the arrangement where the repository is the source of truth, so an edit made in the Confluence UI is drift rather than work, and the next publish is meant to overwrite it.
--force means "always publish", and nothing can suppress the request. It
overrides both checks markfluence update makes without it:
- the refusal to overwrite a page that has moved on since your copy was made, and
- the skip when the page already holds exactly what the file renders to.
The second is why it is not optional here rather than merely convenient: that
check compares against a
local action log
which is per-checkout and deliberately not committed — so a fresh CI checkout
does not have it, and files get reported skipped for a reason nobody in CI
can act on. An action that can be configured into that state is a trap, so
this one cannot be.
Nothing is lost when a UI edit is overwritten: it is in the page's Confluence history, so it can be recovered and applied to the repository properly, which is where it should have been made.
The step fails if any file fails, so the job goes red loudly rather than reporting a partial publish as success.
A file nothing claims is skipped, not failed — it counts in skipped, not
failed — so a glob over a docs tree does not turn the job red when somebody
adds a draft.
results-json is the path to markfluence's schema-locked --json envelope, if
a later step needs the per-file detail. metadata_source on each result says
which location supplied that page's metadata, which is what to look at when a
page lands somewhere unexpected. See
--json output.
It never creates pages. create writes a new page_id back into your
repository, which a workflow has no good way to commit. Create locally, commit
the id, and let CI update from then on.
It never deletes. The file list excludes deletions
(--diff-filter=ACMRT), so removing a markdown file leaves its page alone.
- uses: mozilla/markfluence-action/setup@v1
with:
version: v0.1.0 # optional; defaults to "latest"
- run: markfluence check docs/**/*.md| input | default | description |
|---|---|---|
version |
latest |
Release tag to install, such as v0.1.0. |
| output | description |
|---|---|
version |
The tag actually installed, resolved if you asked for latest. |
It downloads the release archive, verifies it against the release's
checksums.txt, extracts the binary, and runs markfluence --version as a
smoke test — so a bad asset fails in this step rather than three steps later
in the middle of a publish.
There are two independent things to pin, and they come from different places:
| pinned by | decides | |
|---|---|---|
| the action code | the git ref in uses: |
how the action works |
| the markfluence binary | the version: input |
which markfluence you get |
- uses: mozilla/markfluence-action/setup@v1 # <- this repository's tags
with:
version: v0.1.0 # <- markfluence's release tagsThis allows us to ship fixes to the GitHub action separate from markfluence releases.
uses takes a markfluence-action tag or commit SHA. The tag can be a
specific version or the moving v1 major version tag.
version takes a markfluence release tag or latest. Leaving version at
latest is a reasonable default — it resolves to markfluence's most recent
published release — but pin it if you want a run to be reproducible.
make # list the rules
make check # everything CI runs, in CI's orderCI runs make check and nothing else, so what is checked here and what is
checked on a runner cannot drift. Tool versions still can — see the note at
the top of the Makefile. You need shellcheck, actionlint and zizmor
on your PATH.
make test exercises setup/install.sh against real markfluence releases,
so it needs network access. That is deliberate: the thing under test is a
downloader, and a test that stubbed the download would only prove the stub
works.
For maintainers: docs/releasing.md.
Mozilla Public License 2.0, matching markfluence.