Run markfluence in CI to keep Confluence pages in sync with the markdown in your repo: on a push to your default branch, publish the docs that changed.
Configuration in general — including how credentials resolve and what a scoped token needs — is in the README.
You will need to know the Confluence page_id for each page you want to
update.
Store environment variables as encrypted secret (never commit them).
markfluence reads them straight from the environment — no .env in CI.
CONFLUENCE_TOKENCONFLUENCE_URLCONFLUENCE_USERNAME
Prefer a [service account][svcacct] over a personal token here, so published pages
aren't authored by an individual and publishing doesn't break when that person
rotates their token or moves on. That means a scoped token, which also needs
CONFLUENCE_CLOUD_ID (see Scoped tokens and service
accounts). The cloud ID is not sensitive, so
make it a repository variable rather than a secret.
A CI workflow only makes sense when the repository is the source of truth and the Confluence page is a published copy of it. If someone makes changes in the Confluence UI, they will get stomped on when the CI workflow pushes a new change.
run: markfluence update --force docs/**/*.mdupdate --force prevents updates from failing in CI because someone
inadvertently edited the page in the Confluence UI. All edits are in the
Confluence history, so they can be recovered and applied to the repository
correctly.
paths: on the trigger decides whether the job runs. It does not narrow the
glob, so update --force docs/**/*.md republishes every managed page on every
merge — one typo fix bumps the whole tree.
That is worth avoiding for reasons beyond tidiness:
- Confluence notifies watchers on update. Republishing 200 pages emails 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, on an instance whose rate limit is shared with everyone else, and a correspondingly slow job.
Let git pick the files:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # both ends of the push range have to be present
- name: List changed docs
id: changed
run: |
base='${{ github.event.before }}'
# A new branch or a force-push reports an all-zero sha; fall back to
# the first commit so the run publishes the whole tree rather than
# failing on an unknown ref.
if [ -z "${base//0/}" ]; then
base="$(git rev-list --max-parents=0 HEAD | tail -1)"
fi
git diff --name-only --diff-filter=ACMRT "$base" '${{ github.sha }}' \
-- 'docs/**/*.md' > changed.txt
echo "count=$(wc -l < changed.txt)" >> "$GITHUB_OUTPUT"
- name: Publish
if: steps.changed.outputs.count != '0'
env:
# ... as above
run: xargs markfluence update --force < changed.txtTwo details that are easy to get wrong:
--diff-filter=ACMRT(added, copied, modified, renamed, type-changed) excludes deletions. Without it a deleted file lands in the list and fails the run, sinceupdatecannot publish a file that is not there. Deleting a page is deliberately not something a publish does.- The empty-list guard.
updatewith no FILE arguments is an error, so a run where the diff comes back empty has to skip the step rather than invoke it.
This is plain git rather than a marketplace changed-files action, which keeps
one less third-party dependency in the step that holds the Confluence token.
Since UI edits are going to be overwritten, the page should tell readers where they can make edits. Put a callout at the top of the markdown — markfluence converts a GitHub alert into a Confluence panel, so it renders as one:
> [!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.NOTE, TIP, IMPORTANT, WARNING and CAUTION are all supported and keep
GitHub's colours. Linking the source file gives a reader somewhere to go, which
is what turns "do not edit" into something actionable.
Consider restricting page permissions to the publishing account as well, if the space allows it. A banner is a convention; permissions are a mechanism.
If Confluence is the source of truth, you shouldn't be using a workflow to update Confluence. markfluence has no way to discover changes that have been made in the Confluence UI and has no mechanism for reconciling them.
name: Publish docs to Confluence
on:
push:
branches: [main]
paths: ['docs/**.md'] # only when docs change
# Avoid overlapping publishes racing on the same pages.
concurrency:
group: confluence-publish
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
# No release binaries are published yet, so install from source. Pin a tag
# (…@v1.2.3) once releases exist, rather than @latest, for reproducibility.
- name: Install markfluence
run: go install github.com/mozilla/markfluence@latest
- name: Publish
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 public. Omit it if you're
# using an unscoped personal token.
CONFLUENCE_CLOUD_ID: ${{ vars.CONFLUENCE_CLOUD_ID }}
# --force because the repository is the source of truth here; see above.
run: markfluence update --force docs/**/*.mdThat step takes no per-file inputs, and that is the point: each file's page id
and title come from its own frontmatter or from a pages: entry in
markfluence.yaml, so adding a page is a repository change rather than a
workflow change. There are deliberately no --page-id/--title/--page-width
flags — they would each have to name a single file, which is what made
docs/**/*.md inexpressible before.
If your markdown must stay pristine — a README, or a docs tree with other
readers — put every page's metadata in markfluence.yaml:
space: ENG
pages:
docs/deploy-runbook.md:
title: Deploy Runbook
page_id: 12346See the project file.
Notes:
- Exit codes.
updateexits non-zero if any file fails, so the job fails loudly. Add--jsonto get machine-readable per-file results on stdout (see--jsonoutput) if a later step needs to parse them. - A file nothing claims is skipped, not failed, so a glob over a docs tree
does not turn the job red when somebody adds a draft.
metadata_sourcein--jsonsays which location supplied each published page's metadata, which is what to look at when a page lands somewhere unexpected. - Creating pages stays a human act. A workflow creating one would have to
commit the new
page_idback to the repository. Create locally, commit the entry, and let CI update from then on.
A reusable composite/Docker action wrapping this is tracked in #29.