feat(pages): partial page editing without resending whole pages (#22) #7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| # Release automation, in two stages with a human gate between them. | |
| # | |
| # 1. `release-please` watches main and maintains a standing "Release PR" that | |
| # bumps package.json and prepends a CHANGELOG entry, derived from | |
| # Conventional Commit subjects since the last release. Nothing happens until | |
| # a human merges that PR. | |
| # 2. Merging it creates the tag and the GitHub Release, and THIS SAME RUN then | |
| # publishes to npm. | |
| # | |
| # The version is derived mechanically: `fix:` -> patch, `feat:` -> minor, `!` or a | |
| # BREAKING CHANGE footer -> major. A mislabelled commit ships the wrong semver; | |
| # that is the one thing this pipeline cannot check for you. | |
| # | |
| # WHY PUBLISH LIVES HERE rather than in its own `on: release` workflow: | |
| # events created with the default GITHUB_TOKEN do NOT trigger further workflow | |
| # runs. A separate publish workflow keyed on `release: published` would never | |
| # fire, and would fail SILENTLY — release and tag appear, npm never updates. | |
| # Chaining off this job's own `release_created` output avoids that without a | |
| # long-lived PAT. | |
| # | |
| # NOTHING HERE IS THE SECURITY BOUNDARY — the `npm-publish` ENVIRONMENT IS. | |
| # `on: push` runs the workflow file FROM THE PUSHED REF, so a writer can push a | |
| # branch carrying a copy of this file with the branch filter changed and the | |
| # guards deleted, and it will run. npm's trusted publisher binds repository + | |
| # workflow FILENAME — not the ref — so that copy mints exactly the same OIDC | |
| # identity. Removing `workflow_dispatch` did not fix this; it only closed one | |
| # door into the same room. Every check below can be deleted by the copy that | |
| # runs it. | |
| # | |
| # The one field npm validates that a branch copy CANNOT satisfy is the | |
| # environment claim. `environment: npm-publish` on the publish job, an | |
| # environment whose deployment branches are restricted to `main`, and that exact | |
| # name configured on npm's trusted publisher, together mean: GitHub refuses to | |
| # grant the environment to a run on any other ref, and npm refuses a token | |
| # without it. That is why the environment is a PREREQUISITE, not a hardening | |
| # suggestion. See docs/releasing.md. | |
| # | |
| # WHY PUBLISH IS CHAINED OFF THIS RUN rather than `on: release`: events created | |
| # with GITHUB_TOKEN do not trigger further workflow runs, so a separate publish | |
| # workflow would never fire — silently. | |
| on: | |
| push: | |
| branches: [main] | |
| # NO WORKFLOW-LEVEL CONCURRENCY. The two halves need OPPOSITE semantics and one | |
| # shared group cannot give both — see each job's own `concurrency` block. | |
| jobs: | |
| # --------------------------------------------------------------------------- | |
| # RELEASE STATE: the one place release-please state is mutated. Serialized. | |
| # --------------------------------------------------------------------------- | |
| release: | |
| name: Create release | |
| runs-on: ubuntu-latest | |
| # ONE GROUP, `queue: max`, and the two release-please calls kept IN ORDER | |
| # inside this job. All three properties are load-bearing: | |
| # | |
| # * ONE GROUP — release creation and PR grooming both mutate the same | |
| # `autorelease:*` labels and bot branch. `createPullRequests()` computes its | |
| # whole candidate against the tags visible AT THAT MOMENT and only afterwards | |
| # checks for a still-pending merged PR, bailing out only if it finds one. Run | |
| # it beside release creation and this happens: groom scans before the tag | |
| # exists; release creates the tag and flips pending -> tagged; groom's later | |
| # check now finds nothing pending and writes the candidate it computed before | |
| # the release — a plausible, green, WRONG standing Release PR. Splitting these | |
| # into separate jobs (an earlier revision of this file) is what allowed that. | |
| # * IN ORDER, release before groom — that is precisely the ordering the action | |
| # performs in a single invocation, and the reason it is safe. | |
| # * queue: max — the default (`single`) keeps only ONE pending run per group | |
| # and a newer push CANCELS AND REPLACES it. Since only this Release PR's own | |
| # run may release it (see the gate), evicting that run means it is never | |
| # released at all. `max` queues up to 100 instead of evicting. It may not be | |
| # combined with `cancel-in-progress: true` — GitHub rejects the workflow. | |
| concurrency: | |
| group: release-state-${{ github.ref }} | |
| queue: max | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| # Required: release-please drives its state machine with `autorelease:*` | |
| # LABELS and PR comments, which are issue-scoped in the GitHub API. Without | |
| # this the action cannot label the PR it just opened, and the release never | |
| # becomes eligible. | |
| issues: write | |
| outputs: | |
| release_created: ${{ steps.release.outputs.release_created }} | |
| tag_name: ${{ steps.release.outputs.tag_name }} | |
| pr_number: ${{ steps.release.outputs.prNumber }} | |
| steps: | |
| # WHY THIS GATE EXISTS. | |
| # release-please decides what to release by searching the repo's CURRENTLY | |
| # merged PRs for the `autorelease: pending` label — it has no idea which push | |
| # triggered this run. So ANY push's run can create the release for a Release | |
| # PR that merged earlier, tagging that PR's merge commit while this run's | |
| # GITHUB_SHA is an unrelated later commit. npm builds provenance from | |
| # GITHUB_SHA rather than the checkout, so that run would sign a statement | |
| # naming the wrong source. Rather than detect it after the irreversible tag, | |
| # only the run whose own commit IS the pending Release PR's merge may release. | |
| # | |
| # EMPTY IS NOT "ordinary push". Prerequisite #1 makes every main push arrive | |
| # via a merged PR, so "no PR for this commit" is a lie the API tells while | |
| # the association or its labels are still settling — and believing it burns | |
| # the ONE run allowed to release this PR, going green with no tag, no Release | |
| # and no npm version. So: require the PR to exist and to be a real merge of | |
| # THIS commit, retry while that is not yet visible, and only then read the | |
| # label. Nothing here is irreversible, so failing loudly is free: rerun the | |
| # whole run. | |
| - name: Is this push the merge of a pending Release PR? | |
| id: classify | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| # Ask "is a Release PR waiting, and is THIS its merge?" — not "which PR | |
| # does this commit belong to?". | |
| # | |
| # The commit->PR direction was wrong twice over. It has a settling delay, | |
| # so an empty answer is ambiguous; and an earlier revision resolved that | |
| # ambiguity by ERRORING on empty, reasoning that a `pull_request` rule on | |
| # main made every push arrive via a PR. That rule was deliberately not | |
| # applied here (see docs/releasing.md #1: only the owner can push, and the | |
| # owner can already publish), so a direct push to main is ordinary — and | |
| # every one of them turned this workflow red. That is what this rewrite | |
| # fixes. | |
| # | |
| # This direction has no such ambiguity: `autorelease: pending` is attached | |
| # when release-please CREATES the PR, long before it is merged, so at merge | |
| # time it is already there. Empty means "nothing is awaiting release", | |
| # which is the plain truth for a direct push AND for an ordinary feature | |
| # merge. No retry loop, because there is nothing to wait for. | |
| PENDING="$(gh api "repos/${GITHUB_REPOSITORY}/issues?state=closed&labels=autorelease:%20pending&per_page=20" \ | |
| --jq '[.[] | select(.pull_request != null) | .number] | first // empty')" | |
| if [ -z "$PENDING" ]; then | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| echo "no Release PR is awaiting release — nothing to release on this push" | |
| exit 0 | |
| fi | |
| read -r MERGED MERGE_SHA HEAD_REF < <(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PENDING}" \ | |
| --jq '"\(.merged) \(.merge_commit_sha // "-") \(.head.ref)"') | |
| # Structural check, kept: the label alone would let a human-labelled PR | |
| # mint a release. release-please builds either | |
| # `release-please--branches--<target>` or that plus | |
| # `--components--<component>` — this repo produces the SECOND form, which | |
| # is why an exact match on the first would never release anything. | |
| case "$HEAD_REF" in | |
| "release-please--branches--${GITHUB_REF_NAME}"|"release-please--branches--${GITHUB_REF_NAME}--components--"*) ;; | |
| *) | |
| echo "::error::PR #${PENDING} carries autorelease: pending but its branch (${HEAD_REF}) is not one release-please builds. Refusing to release it." >&2 | |
| exit 1 ;; | |
| esac | |
| if [ "$MERGED" != "true" ]; then | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| echo "Release PR #${PENDING} is not merged yet — nothing to release on this push" | |
| elif [ "$MERGE_SHA" != "$GITHUB_SHA" ]; then | |
| # Its own run releases it; this push is a later commit. Skipping here is | |
| # what keeps provenance honest — npm stamps GITHUB_SHA, not the checkout. | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| echo "Release PR #${PENDING} merged as ${MERGE_SHA}, not this push (${GITHUB_SHA}) — its own run releases it" | |
| else | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| echo "this push IS the merge of pending Release PR #${PENDING} — release creation enabled" | |
| fi | |
| # STEP 1 of the state machine: create the release, and nothing else. | |
| # | |
| # Pinned to a reviewed commit, not a moving major tag: this action mints tags | |
| # and GitHub Releases, so "whatever v4 points at that day" is not a supply | |
| # chain worth having. This SHA is v4.4.1, which bundles release-please 17.3.0 | |
| # — the version the bootstrap was validated against (verified via the GitHub | |
| # API, not assumed: an earlier revision pinned a02a34c and called it v4.4.1; | |
| # it is v4.2.0/16.18.0). | |
| # | |
| # skip-github-release takes ONLY "true"/"false" (empty => unset => release | |
| # creation ON), which is why classify always writes one of those two. | |
| - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 | |
| id: release | |
| with: | |
| config-file: release-please-config.json | |
| manifest-file: .release-please-manifest.json | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| skip-github-pull-request: true | |
| skip-github-release: ${{ steps.classify.outputs.skip }} | |
| # "The action succeeded" does NOT mean "the release happened". A Release PR | |
| # with a damaged title, unparseable body or unrecognised branch makes | |
| # buildRelease() return undefined; buildReleases() turns that into an empty | |
| # list; the action then exits 0 WITHOUT setting release_created. `publish` | |
| # would read that as "nothing to do" and skip, leaving the whole run green | |
| # with no tag, no Release and no npm version — and no later run may release | |
| # this PR. That is not hypothetical: the one-time 2.0.0 procedure has a human | |
| # hand-editing that PR body around release-please's parseable structure. | |
| # Nothing is irreversible yet, so fail loudly and let the operator repair the | |
| # PR and re-run. | |
| - name: A release was expected — assert one happened | |
| if: ${{ steps.classify.outputs.skip == 'false' }} | |
| env: | |
| CREATED: ${{ steps.release.outputs.release_created }} | |
| TAG: ${{ steps.release.outputs.tag_name }} | |
| PR: ${{ steps.release.outputs.prNumber }} | |
| run: | | |
| if [ "$CREATED" != "true" ] || [ -z "$TAG" ] || [ -z "$PR" ]; then | |
| echo "::error::This run was the pending Release PR's merge, but release-please created no release (release_created='${CREATED}', tag_name='${TAG}', prNumber='${PR}'). Its title/body most likely no longer parse. No tag exists, so nothing is lost: repair the merged Release PR and re-run this run." >&2 | |
| exit 1 | |
| fi | |
| echo "release ${TAG} created from PR #${PR}" | |
| # STEP 2: groom the standing Release PR — AFTER any release above, never | |
| # beside it. Same job, so the ordering is guaranteed rather than hoped for. | |
| - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 | |
| with: | |
| config-file: release-please-config.json | |
| manifest-file: .release-please-manifest.json | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| skip-github-release: true | |
| publish: | |
| name: Publish to npm | |
| needs: release | |
| # The ONLY publish trigger: release-please created a Release from a merged | |
| # Release PR on main. No manual input, no other ref. | |
| if: ${{ needs.release.outputs.release_created == 'true' }} | |
| runs-on: ubuntu-latest | |
| # Serialize registry writes. Every bare `npm publish` also MOVES the shared | |
| # `latest` dist-tag to the version it publishes — and dist-tags are mutable | |
| # even though versions are not. Two releases merged close together (or, with a | |
| # required reviewer on the environment, approved out of order) can otherwise | |
| # overlap: 2.1.0 lands first, slower 2.0.0 lands second, both commands succeed, | |
| # both GitHub Releases are green — and `npm install` now resolves to 2.0.0. | |
| # None of the tag/package/PR equality checks sees it, because each publish is | |
| # internally consistent. queue: max so a waiting publish is never evicted. | |
| concurrency: | |
| group: npm-publish | |
| queue: max | |
| cancel-in-progress: false | |
| # THE boundary — the only control here a branch copy of this file cannot | |
| # delete. Its deployment-branch policy is enforced by GitHub (a run on any | |
| # other ref is refused the environment, so no OIDC token), and npm's trusted | |
| # publisher requires this exact environment name in the token's claims (so a | |
| # copy that simply drops this line is rejected by npm instead). Removing or | |
| # renaming it silently reopens publish-from-any-branch. See prerequisite #2. | |
| environment: npm-publish | |
| permissions: | |
| contents: read | |
| # Read-only, and only to resolve the Release PR's merge_commit_sha — the | |
| # independent authority the assertion below binds the worktree to. | |
| pull-requests: read | |
| # Required for npm trusted publishing (OIDC) and --provenance. This is what | |
| # lets us publish with NO stored NPM_TOKEN: npm verifies the workflow | |
| # identity directly, and the tarball carries a verifiable link back to this | |
| # commit and run. | |
| id-token: write | |
| env: | |
| TAG: ${{ needs.release.outputs.tag_name }} | |
| steps: | |
| # FULLY QUALIFIED, and that matters: given a bare `v2.0.0`, checkout asks | |
| # `branchExists(origin/v2.0.0)` FIRST and only falls back to the tag. A | |
| # branch and a tag may legally share a short name, so anyone able to push | |
| # `refs/heads/v2.0.0` could hand this trusted, id-token:write job an | |
| # unreviewed worktree — and the version check below would still pass, since | |
| # that branch can declare the same version. `refs/tags/...` is passed | |
| # through untouched and cannot be shadowed. The `main` ruleset does not | |
| # protect `refs/heads/v*`, so this namespace is the only thing that does. | |
| - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | |
| with: | |
| ref: refs/tags/${{ needs.release.outputs.tag_name }} | |
| # THE THREE-WAY EQUALITY: HEAD == Release PR's merge commit == GITHUB_SHA | |
| # | |
| # Each term is load-bearing and none can vouch for the others: | |
| # | |
| # * PR.merge_commit_sha is the only INDEPENDENT authority for what this | |
| # release is supposed to be — it comes from the PR a human merged, not | |
| # from the tag. Needed because release-please does not reject a | |
| # pre-existing tag, and GitHub's Create Release IGNORES target_commitish | |
| # when the tag already exists: a tag pushed at some other commit would | |
| # otherwise get a Release, report release_created, and be installed and | |
| # published here (running ITS prepublishOnly, in a trusted | |
| # id-token:write job). The main ruleset does not protect tags. | |
| # * HEAD proves the worktree we are about to publish is that commit. | |
| # * GITHUB_SHA is what npm stamps into provenance — it does NOT look at the | |
| # checkout. If it differed, the signed statement would name a commit that | |
| # is not the source in the tarball. The classify gate is what makes this | |
| # term true on the normal path. | |
| # | |
| # Deliberately NOT `refs/tags/${TAG}^{}`: that peeled pseudo-ref exists only | |
| # for ANNOTATED tags, and release-please creates LIGHTWEIGHT ones (Create | |
| # Release with a commit SHA). Querying it returns empty with exit 0, so a | |
| # peel-based guard would fail every ordinary release — after the tag. | |
| - name: Assert the worktree is the reviewed Release PR merge | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| PR_NUMBER: ${{ needs.release.outputs.pr_number }} | |
| run: | | |
| if [ -z "$PR_NUMBER" ]; then | |
| echo "::error::release-please reported no Release PR number; refusing to publish." >&2 | |
| exit 1 | |
| fi | |
| # `.merged`, NOT `.state`: a PR closed WITHOUT merging is also | |
| # state=closed, and GitHub still reports a merge_commit_sha for it (and | |
| # for open PRs, where it is merely a test-merge commit). Only `.merged` | |
| # distinguishes an actual merge. | |
| read -r MERGED MERGE_SHA < <(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ | |
| --jq '"\(.merged) \(.merge_commit_sha // "")"') | |
| if [ "$MERGED" != "true" ] || [ -z "$MERGE_SHA" ]; then | |
| echo "::error::Release PR #${PR_NUMBER} is not merged (merged=${MERGED}); refusing to publish." >&2 | |
| exit 1 | |
| fi | |
| HEAD_SHA="$(git rev-parse HEAD)" | |
| if [ "$HEAD_SHA" != "$MERGE_SHA" ]; then | |
| echo "::error::Tag ${TAG} is at ${HEAD_SHA}, but Release PR #${PR_NUMBER} merged as ${MERGE_SHA}." >&2 | |
| exit 1 | |
| fi | |
| if [ "$MERGE_SHA" != "$GITHUB_SHA" ]; then | |
| echo "::error::Release PR #${PR_NUMBER} merged as ${MERGE_SHA}, but this run's commit is ${GITHUB_SHA}; provenance would name the wrong source." >&2 | |
| exit 1 | |
| fi | |
| echo "HEAD == PR #${PR_NUMBER} merge == GITHUB_SHA (${HEAD_SHA})" | |
| # The tag and the code must agree. release-please derives both, so a | |
| # mismatch means something upstream is wrong — and publishing anyway would | |
| # ship a version nobody reviewed under a tag that says otherwise. `npm | |
| # publish` uses package.json, NOT the tag, so this is the check that stops | |
| # tag v2.0.0 from shipping 2.0.1. | |
| - name: Assert the tag matches package.json | |
| run: | | |
| PKG_VERSION="$(node -p 'require("./package.json").version')" | |
| if [ "$TAG" != "v${PKG_VERSION}" ]; then | |
| echo "::error::Tag ${TAG} does not match package.json version ${PKG_VERSION}." >&2 | |
| exit 1 | |
| fi | |
| echo "tag ${TAG} == package.json ${PKG_VERSION}" | |
| - uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 | |
| with: | |
| bun-version: 1.3.14 | |
| # Bun runs the package and its prepublishOnly typecheck; npm is what | |
| # publishes. Both are needed: `npm publish` runs `prepublishOnly` | |
| # (`bun run typecheck`), so a release cannot ship source that does not | |
| # compile — which matters here because the package ships TypeScript source, | |
| # not a bundle. | |
| - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 | |
| with: | |
| node-version: '24.9.0' | |
| registry-url: https://registry.npmjs.org | |
| - name: Install dependencies | |
| run: bun install --frozen-lockfile | |
| # Trusted publishing needs npm >= 11.5.1; the runner's bundled npm may | |
| # predate it. Pinned rather than @latest so the toolchain that publishes is | |
| # the one that was reviewed. | |
| - name: Pin npm | |
| run: | | |
| npm install -g npm@11.6.2 | |
| npm --version | |
| # The concurrency group above orders the JOBS; this orders the OUTCOME. | |
| # GitHub's queue is FIFO by when a job started waiting, which is not | |
| # necessarily release order — and a required reviewer can approve a later | |
| # release first. So check the actual downstream postcondition: never move | |
| # `latest` backwards. Fail closed and let a human decide (a genuine backfill | |
| # should publish under an explicit --tag, not silently steal `latest`). | |
| # | |
| # This is NOT a "does npm already have it?" pre-check — that idea was | |
| # rejected twice: duplicates must stay a loud npm rejection, and an earlier | |
| # revision's version of it collapsed network/auth errors into "absent". Here | |
| # E404 means an empty registry (fine, publish), and any OTHER failure aborts. | |
| - name: Refuse to move `latest` backwards | |
| env: | |
| VERSION: ${{ needs.release.outputs.tag_name }} | |
| run: | | |
| V="${VERSION#v}" | |
| ERR="$(mktemp)" | |
| if LATEST="$(npm view bookstack-mcp-server version --registry=https://registry.npmjs.org 2>"$ERR")"; then | |
| NEWEST="$(printf '%s\n%s\n' "$LATEST" "$V" | sort -V | tail -1)" | |
| if [ "$NEWEST" = "$LATEST" ] && [ "$LATEST" != "$V" ]; then | |
| echo "::error::npm latest is ${LATEST}, newer than ${V}. Publishing would move \`latest\` backwards and npm install would resolve to the older release. If ${V} is a deliberate backfill, publish it by hand with an explicit --tag." >&2 | |
| exit 1 | |
| fi | |
| echo "npm latest is ${LATEST}; publishing ${V} moves it forward" | |
| elif grep -q E404 "$ERR"; then | |
| echo "no published versions yet; ${V} will be the first" | |
| else | |
| echo "::error::Could not read the registry; refusing to publish blind." >&2 | |
| cat "$ERR" >&2 | |
| exit 1 | |
| fi | |
| - name: Publish | |
| run: npm publish --provenance --access public --registry=https://registry.npmjs.org |