OPF Release Process
Use Node 24 (24.x) for all future source, candidate and registry verification.
The packages declare the open-ended engines.node >=22 (RR-20, tested on Node 22, 24 and 26 by the node-range
CI jobs, which run in the merge queue and on pull requests labelled full-ci); a closed range such as 24.x makes npm on any other Node silently install an older release, and
pnpm check:engines-range fails on one, including in a lockfile that pins a release made after the fix.
The next releases must document the Node 24 migration
and use new versions. Historical dual-runtime release records remain unchanged.
This document is the release runbook for the public JavaScript package,
@openpresentation/opf.
The canonical release path is:
- Merge the release commit to
main. - Push a semver tag whose name matches the package version.
- Let GitHub Actions publish to npm through npm trusted publishing.
- Verify npm and the automatically generated GitHub release notes.
Agent authorization and coordinated release order
The owner authorized agents to prepare and publish npm releases on 2026-09-29
and for future releases whenever a release is required. This does not waive any
gate: a release still needs a merged release-prep PR, green required checks and
the verification below. Agents keep publishing on the trusted-publishing
workflows (GitHub Actions OIDC with --provenance); a local npm publish is a
fallback only when a workflow cannot run, and it loses the provenance
attestation that every previous version carries.
The engine packages depend on each other, so publish in this order and wait for each version to appear on the registry before starting the next:
@openpresentation/opf(this repository,opf-vX.Y.Ztag).@openpresentation/opf-render(opf-render-vX.Y.Ztag) and@openpresentation/opf-pptx(opf-pptx-vX.Y.Ztag). Both depend on core; PPTX also devDepends on the renderer, so publish the renderer first.@openpresentation/opf-editor(opf-editor-vX.Y.Ztag), which depends on core and peers/devDepends on the renderer and PPTX.
Each sibling's release-prep PR raises its dependency floors to the just-published
versions. Its lockfile can only be refreshed after the upstream version exists on
npm (npm install --package-lock-only), so merge sibling release PRs only after
the upstream publish. @openpresentation/cli bundles core and is released
separately by cli-publish.yml (cli-vX.Y.Z) when a fresh bundle is needed.
A sibling pull request that needs a core that is not on npm yet (a breaking core release and its sibling PRs, Depends-On: OpenPresentation/opf#N) declares it in its package.json as "opf": { "requiresUnreleasedCore": "X.Y.Z" } (RR-55). Its CI then skips only the packed install against published dependencies (npm run test:packed, with a ::notice::) while the installed published @openpresentation/opf is lower than X.Y.Z; every linked-ecosystem step still runs. The field is for pull requests only: release-train.mjs prep deletes it (and refuses a field above the core of the train), and plan flags a release commit whose package.json still carries it.
Release-prep PRs contain only version bumps, changelog entries, dependency ranges
and lockfile changes (plus current-instruction docs). The changelog entries are
not written by hand: every change adds a fragment changes/<slug>.md in its own
PR (RR-46, changes/README.md), and the release-prep PR
runs the assembler, which moves the fragments into the new release section of
CHANGELOG.md and deletes them. Each sibling repository has the same
changes/ directory and script:
# core (packages are named in each fragment: opf = CHANGELOG.md, cli = packages/cli/CHANGELOG.md)
node scripts/changelog-fragments.mjs assemble --version X.Y.Z --package opf --date YYYY-MM-DD
node scripts/changelog-fragments.mjs assemble --version A.B.C --package cli # only when the CLI is released
# opf-render, opf-pptx, opf-editor
node scripts/changelog-fragments.mjs assemble --version X.Y.Z --date YYYY-MM-DD [--summary "Patch release: ..."]Use --dry-run to preview, and check that no changes/*.md other than
README.md remains for the released package before opening the PR. After the whole set is on
the registry, a follow-up docs change updates release-plan.json, the
compatibility matrix and the quickstart to the published set, and the gallery
consumer dependencies are bumped.
Release train (scripted, RR-51)
scripts/release-train.mjs runs the coordinated release above in lockstep order. It is run by the supervisor with
their own gh login (or GH_TOKEN); every command is a dry run unless --execute is given. It only reads, opens
release-prep pull requests and creates tags: it never merges a pull request and never publishes. Each package is still
published by its own repository's trusted-publishing workflow (OIDC, --provenance) when its tag appears.
Name the versions of the train, any subset: --core X.Y.Z --render X.Y.Z --pptx X.Y.Z --editor X.Y.Z --cli X.Y.Z.
The order is always core, then opf-render, then opf-pptx, then opf-editor and the CLI.
# 1. What is missing (read only; exit 1 until every package is on npm)
node scripts/release-train.mjs plan --core 0.12.1 --render 0.12.1 --pptx 0.12.3 --editor 0.11.3 --cli 0.10.1
# 2. The release-prep PR of the next package, once its upstream is on npm (dry run first: a scratch clone and the diff)
node scripts/release-train.mjs prep render --core 0.12.1 --render 0.12.1 [--item RR-nn]
node scripts/release-train.mjs prep render --core 0.12.1 --render 0.12.1 --item RR-nn --execute
# 3. After a person merged it with green CI: tag the release commit, wait for the publish run and npm, verify
node scripts/release-train.mjs tag render --core 0.12.1 --render 0.12.1 --execute
# 4. Verify any published version (also run by `tag` and `run`)
node scripts/release-train.mjs verify @openpresentation/opf-render@0.12.1
# Or the whole sequence: it stops at the first step that needs a person and prints the command to resume
node scripts/release-train.mjs run --core 0.12.1 --render 0.12.1 --pptx 0.12.3 --editor 0.11.3 --cli 0.10.1 --executeWhat each step checks:
planreads, per package: whether the version is already on npm (then it is only verified), the version in the manifest atmain's head, the release commit (the commit that set the version) and its merged release-prep PR, the required checks on the release commit (themainruleset's required checks; for a repository without a ruleset, every reported check), the tag, whether the upstream versions of the train are on npm, and the dependency floors. It flags every sibling whose@openpresentation/opffloor stays below a new core in the train, with hints from core's fragments or changelog section: the tool cannot know whether a core release moves geometry, so the release owner decides whether the lockstep rule below applies (flag, never decided).preprefuses until every upstream version of the train is on npm. In a scratch clone ofmainit bumps the version, runsnode scripts/changelog-fragments.mjs assemble --version X.Y.Z --date <today>(with--package opfor--package cliin core,--summarywhen given), raises the floors that name a package of the train (caret and tilde ranges keep their operator; exact devDependency pins move to the exact version;workspace:*is untouched; for the CLI,PEER_RANGESinpackages/cli/src/peers.tsfollows its peer ranges) and refreshes the lockfile (npm install --package-lock-onlyin the siblings,pnpm install --lockfile-onlyin core). It fails if a fragment for the package is left or if anything other than the manifest, changelog, fragments, lockfile and peers file changed. The PR body lists README lines that name the previous version for a person to review; prose is not rewritten. It also deletesopf.requiresUnreleasedCorefrom the manifest when the sibling declares it (see above;planflags a release commit that keeps it). Branchcodex/release-<package>-<x-y-z>unless--branchis given. An open release-prep PR (found by branch or by a "release <package> X.Y.Z" title) or a merged one is detected and nothing is written.tagre-verifies right before tagging: the version at the release commit, that the commit is onmain, green required checks, every upstream of the train on npm and every runtime floor naming a version npm has. It then createsrefs/tags/<prefix>X.Y.Z(opf-v,opf-render-v,opf-pptx-v,opf-editor-v,cli-v) withPOST /repos/{repo}/git/refson the release commit (checks that are still running, or not reported yet right after the merge, stop it unless--checks-wait-minutes Nis given: it then polls them every--poll-secondsfor up to N minutes, and a red check still stops it at once), polls that repository's publish workflow run for the tag (default every 120 s, up to 90 min), polls npm until the version is visible, and runsverify. A tag that already exists on the release commit is not created again; one on another commit stops the train. A failed publish run stops with its link: never move or re-push the tag; re-run a transient failure (gh run rerun <id> --failed), fix a real one onmainwith a new version.verifychecks the registry manifest, thatgitHeadequals the tagged commit (and that the commit is onmainand carries the version),dist.attestationswith the SLSA v1 provenance predicate, the provenance statement itself (built by.github/workflows/<publish workflow>of the package's repository onrefs/tags/<tag>from the tagged commit, subject digest equal todist.integrity),npm audit signatures --include-attestationsin a scratch project that installs exactly that version (no invalid or missing signatures; the package has a verified attestation), and the GitHub release where the repository's workflow creates one (core only; the sibling and CLI workflows create none). The dist-tag is reported for information.- Propagation: right after a publish, npm serves the packument (with
dist.attestations.url) minutes before the attestation bundle (HTTP 404{"error":"Not found"}) and before a freshnpm installcan resolve the version (notarget, "No matching version found"); the first live use, core 0.12.1, failedverifyon exactly these two for 2-4 minutes. Only those two checks wait:tagandrunretry the bundle fetch and the scratch install every 30 s (--attest-poll-seconds) for up to 15 min (--attest-wait-minutes; 0 turns the wait off) and say what they wait for in the log. Any answer that is not "not yet there" fails at once without waiting: a bundle that is present but has no SLSA provenance or names another repository, workflow, ref, commit or digest, an invalid or missing signature, any other install error. If the wait runs out, the check fails with "still not propagated after N min". Standaloneverifykeeps failing fast;verify <package>@X.Y.Z --wait <minutes>gives it the same retry (for example right after a publish run you started by hand).
- Propagation: right after a publish, npm serves the packument (with
runrepeatsplanper package in order and does the next step: verify what is on npm, stop at an open release-prep PR, open a missing one (prep), wait for pending checks on a merged release commit, thentag. Re-running it after a stop skips every step already done; a version already on npm is never published again.
The follow-up docs PR (release-plan.json, the compatibility matrix and the quickstart) and the gallery consumer bumps
stay by hand after the train.
.github/workflows/release-train.yml is the same script as a workflow_dispatch (inputs: the versions and mode).
Until the GitHub App of opf#298 exists it is plan-only: tags
pushed with a workflow's GITHUB_TOKEN start no other workflow (so the publish workflows would never run), and
GITHUB_TOKEN cannot push branches or open pull requests in the sibling repositories, so mode: execute fails at
once. With the App (ECOSYSTEM_APP_ID variable and ECOSYSTEM_APP_PRIVATE_KEY secret, the roller's App) mode: execute runs run --execute with the App's token.
The steps by hand below remain the fallback when the script cannot run.
Geometry-moving core releases: lockstep floors
Core composition changes that move geometry (for example opf#169 cover centering) make the preview and the PPTX export drift when @openpresentation/opf-render and @openpresentation/opf-pptx resolve different core versions (measured: 186-300 pt title offsets on covers).
Rule: when a core release contains composition or geometry changes, the same release train must raise BOTH the renderer's and PPTX's core floor (dependencies and, where present, peerDependencies) to that core version, publish them together, and raise the editor's floor too. Do not release core alone and leave a sibling on the older floor.
The parity harness must always run with --import <opf>/scripts/register-local-opf.mjs (as run.ps1 does) so every engine shares one core.
Release Preconditions
Before tagging, confirm that the release commit on main already contains:
packages/javascript/package.jsonwith the intended version.CHANGELOG.mdwith the matching release section (assembled fromchanges/, see above).- Passing
OPF CIon the release commit.
The publish workflow validates the tag name against
packages/javascript/package.json, so the tag must point at the release commit.
Tag And Publish
Use the opf-vX.Y.Z tag form for the package release:
git checkout main
git pull origin main
grep '"version"' packages/javascript/package.json
git tag opf-vX.Y.Z
git push origin opf-vX.Y.ZFor example, version 0.3.0 used:
git tag opf-v0.3.0
git push origin opf-v0.3.0Pushing the tag triggers .github/workflows/npm-publish.yml. The workflow:
- runs on tags matching
opf-v*or@openpresentation/opf@v* - installs dependencies with pnpm on Node 24
- verifies the tag matches
packages/javascript/package.json - runs typecheck and tests
- runs the npm package dry-run check
- publishes from
packages/javascriptwithnpm publish --access public --provenance
Do not rerun a successful publish for the same version. npm package versions are immutable; a second publish for an already-published version should fail.
Trusted Publishing
npm publishing is configured to use GitHub Actions OIDC trusted publishing, not a long-lived npm token.
Expected npm package trusted-publisher settings:
| Setting | Value |
|---|---|
| Package | @openpresentation/opf |
| Publisher | GitHub Actions |
| Organization/repository | OpenPresentation/opf |
| Workflow filename | npm-publish.yml |
| Environment | empty, unless the workflow is later moved behind a GitHub Environment |
| Permission | npm publish |
Expected workflow settings:
permissions:
contents: read
id-token: writeThe publish step should not set NODE_AUTH_TOKEN:
- name: Publish to npm
working-directory: packages/javascript
run: npm publish --access publicIf a future release fails with npm authentication errors, check the npm
trusted-publisher settings first. Only use an NPM_TOKEN repository secret as a
temporary fallback, and remove or revoke it once OIDC publishing works again.
Verify The Release
node scripts/release-train.mjs verify <package>@X.Y.Z runs every registry check of this section and the provenance
checks (see "Release train" above). By hand, after the workflow completes, verify npm:
npm view @openpresentation/opf versionThe output should equal the package version that was tagged.
Spot-check the validator API from a clean project or temporary directory:
npm install @openpresentation/opf@X.Y.Z
node --input-type=module -e "import {validate} from '@openpresentation/opf'; console.log(validate({name:'t', narrative:'not-a-real-id', slides:[{title:'t'}]}, {only:['format','references']}).findings)"The expected result is one warning about an unknown narratives catalog id.
GitHub Release Notes
The core tag workflow creates a GitHub Release from the matching changelog section after publishing. Verify that release after npm is verified. If release creation failed, create the missing release for the existing tag:
gh release create opf-vX.Y.Z \
--repo OpenPresentation/opf \
--title '@openpresentation/opf X.Y.Z' \
--notes-file /path/to/release-notes.mdUse the matching ## X.Y.Z section from CHANGELOG.md as the release notes.
Troubleshooting
If the tag/version check fails, the tag does not point at the release commit or
the tag name does not match packages/javascript/package.json. Delete the bad
local and remote tag, fetch main, and tag the correct commit:
git push origin :refs/tags/opf-vX.Y.Z
git tag -d opf-vX.Y.Z
git checkout main
git pull origin main
git tag opf-vX.Y.Z
git push origin opf-vX.Y.ZIf tests fail, fix the code on main, create a new release commit, and move the
tag only if npm has not already published that version.
If npm publish fails with ENEEDAUTH, confirm:
- npm has a trusted publisher for
OpenPresentation/opf - the trusted publisher uses workflow filename
npm-publish.yml .github/workflows/npm-publish.ymlhasid-token: write- the publish job is running on a modern Node/npm toolchain
If npm publish fails after the version is already present on npm, do not retry the same publish. Verify the package and treat the failure as a duplicate publish attempt.