From 2531ec7a12483e41c56b4f4a47b7d99fe4531c9d Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Sun, 13 Sep 2026 06:53:02 -0400 Subject: [PATCH 1/3] docs(confluence): record what bumps a page's version Measured for #149, which needs the page version to mean "the content moved on". It does: version.number tracks the body and the title and nothing else. Neither content property write, neither label write, and an attachment upload all leave it -- and version.createdAt -- exactly as they were, while a body or title change bumps both. Two consequences the issue depends on. A publish advances the page by exactly one version, even though update writes width, labels and attachments after the body, so the version the body PUT returned is still the live one afterwards. And that returned version is what a later GET reports, so a caller recording what it published needs no re-read. A content property also carries its own version counter -- it went 1 -> 2 across a create-then-update while the page stayed at 1 -- so a property's version says nothing about the page's in either direction. Cross-referenced from labels.md, page-width.md and attachments.md, since each is a place someone would look first for "does this write touch the page". Not verified, and said so: whether the v1 move route (#10) bumps the version, and whether a UI version restore does. --- docs/confluence/api.md | 43 ++++++++++++++++++++++++++++++++++ docs/confluence/attachments.md | 5 ++++ docs/confluence/labels.md | 12 ++++++++++ docs/confluence/page-width.md | 6 +++++ 4 files changed, 66 insertions(+) diff --git a/docs/confluence/api.md b/docs/confluence/api.md index 313c558..140cd4a 100644 --- a/docs/confluence/api.md +++ b/docs/confluence/api.md @@ -244,6 +244,49 @@ else's content is far worse than reporting a failure that actually succeeded. triggers on *any* error specifically so nothing depends on the answer; guessing a status and getting it wrong would leave the recovery silently never firing. +## What bumps a page's version + +A page's `version.number` tracks the **body and the title, and nothing else**. +Every other write markfluence makes to a page leaves it alone, which is what +makes it usable as a record of "has this page's content moved on?" (#149). + +**Verified 2026-09-13** against a scratch page in a personal space, created at +version 1, through the gateway: + +| write | route | page version | +|---|---|---| +| create a content property | `POST /wiki/api/v2/pages/{id}/properties` | **unchanged** | +| update a content property | `PUT /wiki/api/v2/pages/{id}/properties/{propId}` | **unchanged** | +| add a label | `POST /wiki/rest/api/content/{id}/label` | **unchanged** | +| remove a label | `DELETE /wiki/rest/api/content/{id}/label?name=` | **unchanged** | +| upload an attachment | `PUT /wiki/rest/api/content/{id}/child/attachment` | **unchanged** | +| change the body | `PUT /wiki/api/v2/pages/{id}` | **+1**, new `createdAt` | +| change only the title | `PUT /wiki/api/v2/pages/{id}` | **+1**, new `createdAt` | + +`version.createdAt` moves with the number and stays put when it does, so the +timestamp is no more sensitive than the counter — adding a label does not +re-stamp the page. + +A content property carries **its own independent version counter**: across the +create-then-update above it went 1 → 2 while the page stayed at 1. So a +property's version says nothing about the page's, in either direction. + +Two consequences, both load-bearing. + +**A publish advances the page by exactly one version.** `update` writes the body +and *then* applies width (two content properties), labels, and attachments, so a +naive reader might expect the live version to be several ahead of the one the +body PUT returned. It is not. Nothing after the body write touches it. + +**The version in the `PUT /pages/{id}` response is the version a later GET +reports**, checked on both publishes above, so a caller recording what it +published does not need a re-read to learn the number. + +**Not verified:** whether a *move* (the v1 `content/{id}/move` route, #10) +bumps the version, and whether restoring a previous version from the UI does. +Measured once, on one Cloud instance, through the gateway, with a personal +token. + ## Scopes **Derived 2026-08-20** from Atlassian's own OpenAPI documents, one lookup per diff --git a/docs/confluence/attachments.md b/docs/confluence/attachments.md index e00832f..2b8b10b 100644 --- a/docs/confluence/attachments.md +++ b/docs/confluence/attachments.md @@ -171,5 +171,10 @@ The comment shows up under `extensions` too. Top-level keys are `ari`, `base64EncodedAri`, `extensions`, `id`, `macroRenderedOutput`, `metadata`, `status`, `title`, `type`, `version` — `title` being the stored attachment name. +An attachment has a version of its own, and **uploading one does not bump the +*page's* version** (verified 2026-09-13) — so a publish's attachment pass +leaves the page at the version its body write produced. The full table of what +does and does not bump it: [api.md](api.md#what-bumps-a-pages-version). + See [api.md](api.md) for how these collections paginate and how downloads redirect. diff --git a/docs/confluence/labels.md b/docs/confluence/labels.md index 601d372..0fdc9c5 100644 --- a/docs/confluence/labels.md +++ b/docs/confluence/labels.md @@ -167,6 +167,18 @@ assert-exactly rule visibly, which is the better of the two available outcomes. Found in review, after the end-to-end run below had already passed — that run used `my:mine`, a name nothing collided with. +### A label write does not bump the page version + +**Verified 2026-09-13**: adding a label and removing one both leave the page's +`version.number` *and* its `version.createdAt` exactly as they were, while a +body change bumps both. Details and the full table of what does and does not +bump it: [api.md](api.md#what-bumps-a-pages-version). + +Two things follow. A label change is invisible to anything watching the page +version, so labels cannot be tracked that way. And `labels.Apply` running after +a publish does not advance the page past the version that publish produced, +which is what lets a caller record the version it published without re-reading. + ### v2 is read-only for labels | request | result | diff --git a/docs/confluence/page-width.md b/docs/confluence/page-width.md index e957826..9d608b6 100644 --- a/docs/confluence/page-width.md +++ b/docs/confluence/page-width.md @@ -19,6 +19,12 @@ content-appearance-draft -> ["max"] Both set, both agreeing. This is why `pagewidth.Apply` writes the pair rather than just the published one. +Writing either property leaves the page's `version.number` untouched, and each +property carries a version counter of its own — see +[api.md](api.md#what-bumps-a-pages-version). So a width change is invisible to +anything watching the page version, and `pagewidth.Apply` running after a +publish does not advance the page past the version that publish produced. + ## The vocabulary Authors write the UI's words in frontmatter; the property takes a different set From 77f295ef6f24f7c1f43129615dacf28febc88b17 Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Sun, 13 Sep 2026 07:22:04 -0400 Subject: [PATCH 2/3] docs(github-actions): name the source of truth, and recommend --force A CI workflow only makes sense when the repository is the source of truth and the page is a published copy, and saying so decides two things the page did not answer. --force, because without it publishing is non-deterministic rather than merely unprotected. git does not preserve mtimes, so a fresh checkout stamps every file with the clone time, and the skip compares that against Atlassian's clock -- so whether a page publishes depends on wall-clock ordering between two unsynchronized clocks, and one that should have synced can be reported skipped. The mtime check exists to protect an edit made in Confluence, which is the thing this arrangement deliberately does not want (#149). And a callout on the page, since a UI edit here is drift that the next push overwrites. A GitHub alert converts to a Confluence panel, so the mechanism already exists; linking the source file is what makes "do not edit" actionable. Permissions are suggested as the mechanism a banner only approximates. Plus an explicit "do not run this in CI" for the inverse arrangement, where Confluence is the source of truth: update has no record of which page version a local copy came from, so two divergent publishes resolve to whoever went last. --- docs/github-actions.md | 46 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/docs/github-actions.md b/docs/github-actions.md index 66ce03d..066ab4a 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -27,6 +27,49 @@ make it a repository **variable** rather than a secret. [secrets]: https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions +## The source of truth is external to Confluence + +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. + +### Use `--force` + +```yaml + run: markfluence update --force docs/**/*.md +``` + +`update --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. + +### Say so on the page + +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: + +```markdown +> [!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, do not run this workflow + +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. + ## Workflow ```yaml @@ -65,7 +108,8 @@ jobs: # 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 }} - run: markfluence update docs/**/*.md + # --force because the repository is the source of truth here; see above. + run: markfluence update --force docs/**/*.md ``` That step takes no per-file inputs, and that is the point: each file's page id From ecacd737c283140a429891cb7e0307d1383dc922 Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Sun, 13 Sep 2026 08:34:58 -0400 Subject: [PATCH 3/3] docs(github-actions): publish only the files that changed paths: on the trigger gates whether the job runs, not what the glob matches, so update --force docs/**/*.md republishes the whole tree on every merge. The reason that matters is not tidiness: Confluence notifies watchers on update, so one typo fix emails everyone watching any of 200 pages, and a run of identical new versions makes the page history unanswerable. The recipe uses plain git rather than a marketplace changed-files action, since this is the step holding the Confluence token. Two details it gets wrong easily: --diff-filter=ACMRT excludes deletions, which would otherwise land in the list and fail the run because update cannot publish a file that is not there; and an empty diff has to skip the step, because update with no FILE arguments is an error. The all-zero base sha from a new branch or a force-push falls back to the first commit, so that case publishes everything rather than failing on an unknown ref. --- docs/github-actions.md | 57 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/github-actions.md b/docs/github-actions.md index 066ab4a..16fee2c 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -45,6 +45,63 @@ 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. +### Publish only the files that changed + +`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: + +```yaml + - 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.txt +``` + +Two 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, since `update` cannot publish a file that is not there. Deleting a page + is deliberately not something a publish does. +- **The empty-list guard.** `update` with 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. + ### Say so on the page Since UI edits are going to be overwritten, the page should tell readers where