Aller au contenu

Publications

Releasing

How OSS releases are cut, built, published, and tagged.

Ce contenu n’est pas encore disponible dans votre langue.

This document is for maintainers cutting a release. End users should look at README.md; contributors at CONTRIBUTING.md. The Homebrew tap layout is documented separately in specs/homebrew-tap.md.

All packages in this monorepo (the @nowline/* npm packages and the VS Code extension) share a single version and ship together. The release pipeline lives in .github/workflows/release.yml.

We use Semantic VersioningMAJOR.MINOR.PATCH, tagged vMAJOR.MINOR.PATCH. Every package in packages/* is kept lock-step at the same version.

  • 0.x.y — public API and AST JSON schema may change between minor versions; patch releases are bug-fix only. Call out breaking changes in the CHANGELOG.
  • 1.0.0 and afterMAJOR is reserved for breaking changes to the DSL, AST schema, or CLI surface. Minors add features without breaking. Patches are bug-fixes.

The DSL itself uses an independent integer-only version (nowline v1, v2, …) declared inside .nowline files; that contract lives in specs/dsl.md and is not tied to package SemVer.

packages/*/package.json#version always reflects the last released version on main. To keep dev builds distinguishable from real releases without rewriting package.json between every commit, the CLI appends git build metadata to its --version output (per SemVer §10):

Buildnowline --version
Tagged release (HEAD == vX.Y.Z)0.1.0
Dev build, clean tree0.1.0+abc1234
Dev build, uncommitted changes0.1.0+abc1234.dirty

The +... suffix is informational metadata only; npm and the VS Code Marketplace strip / reject it on their own version fields, so it never reaches a published artifact. The metadata is captured at compile time by packages/cli/scripts/bundle-templates.mjs (which shells out to git rev-parse, git describe --exact-match, and git status --porcelain).

Before cutting a release, on main:

  1. CI is green on the latest main commit (Linux, macOS, Windows).
  2. CHANGELOG.md is up to date. See Changelog workflow below — contributors should already have appended entries to ## [Unreleased] as part of their PRs; the cut-release job automatically moves them into a new ## [vX.Y.Z] - YYYY-MM-DD section. No manual edit is required before triggering the dispatch.
  3. Examples render cleanly. pnpm build (which runs pnpm samples and pnpm fixtures) should produce the expected SVGs without warnings.
  4. Smoke-test the standalone binary locally with pnpm --filter @nowline/cli compile:local, then run examples/minimal.nowline through every export format using the platform-suffixed binary the script produces — ./packages/cli/dist-bin/nowline-<platform>-<arch> (e.g. nowline-macos-arm64 on Apple Silicon, nowline-linux-x64 on Linux/amd64). This catches bun compile regressions that the CI smoke test cannot reach for cross-platform binaries.
  5. Required secrets are in place. All five secrets in the Required secrets table below must exist on lolay/nowline. The first-ever release also needed the lolay/homebrew-tap repo seeded and the Marketplace / Open VSX namespaces created — that one-time setup is now done; subsequent releases skip it. (Forking maintainers can recreate the namespaces from each registry’s “create publisher” flow; the formula seed lives at scripts/homebrew-tap/.)

There are two ways to trigger a release; the dispatch UI is the default.

  1. Go to Actions → Release → Run workflow on lolay/nowline.
  2. Pick the level (patch / minor / major).
  3. Click Run workflow.

This kicks off the cut-release job, which:

  1. Checks out main using RELEASE_TAG_PAT (a user-scoped PAT — GITHUB_TOKEN-pushed tags do not trigger downstream workflows, which would defeat the whole point).
  2. Runs node .github/scripts/bump-version.mjs <level> to rewrite every packages/*/package.json to the next SemVer.
  3. Runs node .github/scripts/release-changelog.mjs <version> to promote every entry under ## [Unreleased] in CHANGELOG.md and packages/vscode-extension/CHANGELOG.md into a new ## [X.Y.Z] - YYYY-MM-DD section, leaving an empty ## [Unreleased] skeleton in place.
  4. Commits the bump and changelog promotion as release vX.Y.Z.
  5. Tags vX.Y.Z.
  6. Pushes both the commit and the tag to main.

The tag push then re-triggers release.yml under event_name == 'push', which runs the actual build/publish jobs (the cut-release job is gated to dispatch-only; the build/publish jobs are gated to tag-pushes-only).

If the dispatch flow is unusable (e.g. PAT expired), you can do the same thing locally:

Terminal window
node .github/scripts/bump-version.mjs patch # or minor / major; prints new version
node .github/scripts/release-changelog.mjs X.Y.Z # or: make release-changelog VERSION=X.Y.Z
git commit -am "release vX.Y.Z"
git tag vX.Y.Z
git push origin main
git push origin vX.Y.Z

The tag push triggers the same downstream jobs.

Motivating incident. The v0.3.0 release run (#26337633859) failed because CI did not exercise the same build surface as the release. The root-cause fix is the dual-event design below: every PR now runs the same 10-cell matrix the release does.

flowchart LR
prOpen["Open PR / push to PR head"] --> ci["ci.yml \n lint-workflows + build-test + bundle-size \n + release-build-smoke -> build.yml (upload: false) \n PR pushes cancel prior in-flight runs"]
mergePush["Squash-merge to main"] --> ci
bumppush["cut-release bump commit on main \n (author = nowline-release-bot)"] --> ciLight["ci.yml \n lint-workflows + build-test + bundle-size \n (release-build-smoke skipped by author filter) \n main pushes never cancel"]
tagpush["Tag v* push"] --> reuse2["release.yml -> build.yml (upload: true)"] --> publish["publish matrix"]
dispatch["workflow_dispatch"] --> cut[cut-release] --> bumppush
cut --> tagpush
branchPush["Push to branch \n (no PR)"] -.->|no trigger| void["(nothing runs)"]

build.yml is the single source of truth for the 10-cell build matrix. ci.yml calls it with upload: false on every PR commit and every squash-merge to main; release.yml’s build job calls it with upload: true, gated on tag push. The upload flag gates only the actions/upload-artifact steps — every build, pack, and verify step always runs, so PRs exercise the exact surface a tag push would.

publish does needs: build, so GitHub Actions waits for every cell of the build matrix to succeed before any cell of publish starts — that is the gate, no separate job required. Inside the github-release cell of publish, the homebrew tap commit runs as the last sequential step, so the tap fires right after the GH release publish without waiting on the npm or vscode cells.

ci.yml’s release-build-smoke job carries this if: condition:

if: ${{ github.event_name != 'push' || github.event.head_commit.author.email != 'nowline-release-bot@lolay.com' }}

This skips the heavy 10-cell matrix on the version-bump commit that cut-release produces, because release.yml is already about to run the same matrix via build.yml with upload: true. Running both in parallel would waste compute without adding any safety signal — both are reading the same SHA.

The filter uses head_commit.author.email rather than commit-message matching because a human PR could coincidentally start with “release v…”. The email nowline-release-bot@lolay.com is set via the GIT_AUTHOR_EMAIL env variable at the top of release.yml. Cross-reference contract: if that email ever changes, the if: condition in ci.yml’s release-build-smoke job must be updated to match — otherwise bump commits either silently re-run the full matrix in parallel (wasted compute) or the filter stops working (infinite-loop risk during release).

Dispatch-only. Bumps versions, commits, tags, and pushes. See Cutting the release.

Defined in build.yml. One job, one matrix, ten cells. Heterogeneous on purpose so the publish phase can needs: build and inherit “wait for every cell” gating from GitHub Actions for free.

Cell idRunnerProduces
bin-macos-arm64macos-latestnowline-macos-arm64 artifact
bin-macos-x64macos-latestnowline-macos-x64 artifact
bin-linux-x64ubuntu-latestnowline-linux-x64 + nowline_amd64.deb artifacts
bin-linux-arm64ubuntu-latestnowline-linux-arm64 + nowline_arm64.deb artifacts
bin-windows-x64windows-latestnowline-windows-x64.exe artifact
bin-windows-arm64windows-latestnowline-windows-arm64.exe artifact
pack-npmubuntu-latestnpm-tarballs artifact (eighteen .tgz files)
pack-vsixubuntu-latestnowline-vscode.vsix artifact
pack-actionubuntu-latestaction-mirror bundle artifact
pack-mcp-mcpbubuntu-latestnowline.mcpb artifact (Claude Desktop Extensions bundle built from packages/mcp/)

Binary cells use bun compile and run the same per-format smoke test (SVG, PNG, PDF, HTML, Mermaid, XLSX, MS Project XML) against examples/minimal.nowline, except cross-target combinations that cannot execute on the runner. The two linux cells additionally invoke scripts/build-deb.sh on the binary they just produced — keeping the binary→deb chain intra-cell skips an artifact upload/download round-trip.

pack-npm runs pnpm pack for the eighteen publishable packages in dependency order (@nowline/core, @nowline/layout, @nowline/renderer, @nowline/browser, @nowline/embed, @nowline/preview-shell, @nowline/lsp, @nowline/lsp-worker, @nowline/export-core, the six per-format @nowline/export-* packages, @nowline/config, @nowline/cli, @nowline/mcp). pnpm 10 rewrites workspace:* to the resolved version inside each tarball, so the publish phase uses plain npm publish <tarball> with no workspace-protocol shenanigans.

Note on @nowline/lsp. As of v0.4.0, @nowline/lsp is published to npm. @nowline/lsp-worker declares "@nowline/lsp": "workspace:*" in its runtime dependencies; pnpm rewrites that to the resolved version inside the published tarball, so npm install @nowline/lsp-worker needs @nowline/lsp resolvable on the registry. Publishing @nowline/lsp also fulfills the contract documented in its own README (npx nowline-lsp for Neovim/JetBrains/Helix/Emacs). The alternative — bundling @nowline/lsp into @nowline/lsp-worker via esbuild — was evaluated and deferred: lsp-worker has no bundler config today (tsc -b only), and introducing one would require designing externals for all four entry points plus langium/vscode-languageserver; that’s a separate project, not a pre-release patch.

Note on @nowline/config. As of v0.4.0, @nowline/config is also published to npm. @nowline/cli declares "@nowline/config": "workspace:*" in its runtime dependencies; pnpm rewrites that to the resolved version inside the published tarball, so npm install -g @nowline/cli needs @nowline/config resolvable on the registry. The primary distribution channels (Homebrew, .deb, GitHub Releases, VS Code Marketplace) are unaffected — they use bun compile binaries where @nowline/config is bundled at compile time.

pack-vsix runs pnpm package in packages/vscode-extension, which produces dist/nowline-vscode.vsix via esbuild + vsce package --no-dependencies. The .vsix bundles the workspace dependencies, so the vscode publish cell never needs to read from npm.

pack-action stages the Marketplace action mirror bundle for lolay/nowline-action.

pack-mcp-mcpb runs make pack-mcpb, producing dist-mcpb/nowline.mcpb for Claude Desktop Extensions. Marketplace distribution (registry publish, .mcpb attach, manual submission tracking) is handled by the separate publish-mcp job — see § MCP publishing artifacts and ops/mcp-marketplace.md.

Option B — version-from-tag (future direction). Today the cut-release commit bumps packages/*/package.json#version, which is what stamps the binary’s --version, the embed banner, the .deb control file, the .vsix manifest, and each pnpm pack tarball. A future refactor could keep package.json at 0.0.0-development permanently and inject version from the tag at build time, enabling true cache-by-SHA across CI and release — PRs would build the exact same artifacts as the release without any version-bump commit in the way. Out of scope for v0.4.0; revisit after estate cleanups.

A single job with one matrix of four cells. needs: build means every cell of the build matrix must succeed before any cell here starts — built-in gating, no no-op job. Each cell downloads only the artifacts it needs, then pushes; there is no pnpm install or pnpm -r build happening alongside any external upload.

Cell idAction
npmDownloads npm-tarballs, runs npm publish <tarball> --access public for each tarball in dependency order. Uses NPM_TOKEN.
vscodeDownloads nowline-vscode.vsix, runs vsce publish --packagePath … then ovsx publish …. Uses VSCE_PAT and OVSX_PAT.
github-releaseDownloads binary + deb artifacts, stages them with the man page (nowline.1) and any nowline.<locale>.1 overlays, publishes the GitHub Release via softprops/action-gh-release@v3, then commits a refreshed Formula/nowline.rb to lolay/homebrew-tap using HOMEBREW_TAP_TOKEN. The formula references the release-asset URLs that the same cell just published and embeds SHA256s computed on the fly. The cell fails loudly if any expected artifact is missing rather than emitting an all-zero SHA. See specs/homebrew-tap.md for the formula structure and seed-repo bootstrap.

| action-mirror | Downloads action-mirror bundle, syncs to lolay/nowline-action, tags, and publishes a GitHub Release on the mirror. Uses MARKETPLACE_MIRROR_PAT. |

The matrix uses fail-fast: false so a flaky npm publish does not cancel an in-flight Marketplace publish or the github-release/tap cell.

Defined in publish-mcp.yml. Reusable workflow invoked from release.yml with needs: [build, publish] so the MCP registry pointer is never pushed for a half-failed release or before npm is live. A thin standalone caller (publish-mcp-standalone.yml) reuses the same implementation for maintainer re-runs.

Ordered steps inside publish-mcp.yml:

  1. Verify npm view @nowline/mcp@$VERSION resolves (waits out npm propagation lag).
  2. Attach nowline.mcpb to the GitHub Release (gh release upload --clobber).
  3. make publish-mcp-registry — DNS domain auth + mcp-publisher publish (last automated step).
  4. Open a maintainer-only + release-ops GitHub issue with the Claude Desktop + Gemini manual submission checklist.

Uses MCP_PRIVATE_KEY. Operator runbook: ops/mcp-marketplace.md.

We deliberately ship every tag as a stable release — Marketplace pre-release channels require SemVer pre-release suffixes (e.g. 0.1.0-rc.1) that we do not currently produce. Revisit at 1.0 if we want a “next” channel.

Why the homebrew tap commit lives inside the github-release cell

Section titled “Why the homebrew tap commit lives inside the github-release cell”

GitHub Actions matrix cells cannot depend on each other (no intra-matrix needs:). The tap commit must run after the GH release publish because the formula references releases/download/v…/… URLs that have to resolve. Folding the tap steps into the github-release cell as sequential steps on the same runner gives “tap fires right after release, doesn’t wait for vscode or npm” with no extra job, no inter-job artifact re-download, and no separate gate. The trade is that re-running the cell after a tap-only failure also re-attempts the GH release publish; softprops/action-gh-release@v3 is upsert-style on the same tag and overwrites asset uploads, so re-runs are safe.

  • nowline-macos-arm64, nowline-macos-x64
  • nowline-linux-x64, nowline-linux-arm64
  • nowline-windows-x64.exe, nowline-windows-arm64.exe
  • nowline_amd64.deb, nowline_arm64.deb
  • nowline.1 (man page; referenced as a Homebrew resource), plus any nowline.<locale>.1 overlays.
  • nowline.mcpb (Claude Desktop Extensions bundle; attached by publish-mcp.yml after the main publish matrix is green).

When a released line needs a fix without dragging in newer work from main:

  1. Cut a release/vX.Y branch from the tag you need to patch (git switch -c release/v0.1 v0.1.0) and push it.
  2. Open a PR against that branch with the fix.
  3. Apply the backport main label.
  4. After CI passes, merge. .github/workflows/backport.yml (using korthout/backport-action) auto-opens a follow-up PR cherry-picking the squash-commit onto main. Reviewer validates CI on the backport PR and merges it. Auto-merge is intentionally off because cherry-picks can conflict with newer work on main.
  5. Cut a new tag from release/vX.Y (v0.1.1) via the manual Release workflow dispatch (run it from the release/v0.1 branch via the Use workflow from dropdown). The tag itself does not need to live on main; the published binaries / packages just need the right code at the right SHA.

All secrets live under Settings → Secrets and variables → Actions on lolay/nowline.

SecretUsed byPurpose
RELEASE_TAG_PATcut-releaseUser-scoped PAT (fine-grained, contents: write on lolay/nowline) used to push the release commit + tag. GITHUB_TOKEN-pushed tags do not trigger downstream workflow runs, which would prevent the build/publish jobs from firing.
NPM_TOKENpublish (npm cell)npm publish for @nowline/* packages. Use an automation token.
VSCE_PATpublish (vscode cell)Azure DevOps personal access token with Marketplace → Manage scope, scoped to the nowline publisher.
OVSX_PATpublish (vscode cell)Open VSX personal access token.
HOMEBREW_TAP_TOKENpublish (github-release cell)Fine-grained PAT with contents: write on lolay/homebrew-tap for committing the refreshed formula.
MCP_PRIVATE_KEYpublish-mcpEd25519 private key (64-character hex) for DNS domain auth on nowline.io. Grants io.nowline/* namespace for automated MCP registry publish. One-time DNS TXT record setup — see ops/mcp-marketplace.md.
MARKETPLACE_MIRROR_PATpublish (action-mirror cell)Fine-grained PAT with contents: write on lolay/nowline-action for mirroring the Marketplace action bundle.

@nowline/mcp ships to more channels than any other Nowline artifact because each agent harness has its own discovery mechanism. All OSS distribution builds and releases from lolay/nowline via release.yml, independently of the .vsix.

ChannelArtifactAction at release
npm@nowline/mcp tarballPublished automatically by the npm publish cell (same as all other @nowline/* packages).
Public MCP registry (io.nowline/nowline)Registry entry (JSON metadata + npm package ref)Automated by publish-mcp.yml via mcp-publisher login dns + mcp-publisher publish (runs after npm is live; registry publish is the last automated step). Feeds the VS Code MCP gallery and Cursor Marketplace.
Cursor MarketplaceRegistry-sourced listingNo separate action — reads the public MCP registry.
VS Code MCP galleryRegistry-sourced listingNo separate action — reads the public MCP registry.
GitHub Releasenowline.mcpbAutomatedpublish-mcp.yml attaches the artifact built by pack-mcp-mcpb.
Claude Desktop Extensions directorynowline.mcpbManual submission (no public API). CI opens a maintainer-only tracking issue with checklist + link to ops/mcp-marketplace.md.
Gemini CLI Extension channelExtension bundle + GEMINI.mdManual submission per the Gemini CLI extension publishing process; tracked on the same release issue.

Curated marketplace submissions (Claude Desktop Extensions, public MCP registry, Gemini CLI extension channel) may require a review period of hours to days for initial acceptance. Subsequent updates to the same publisher id generally receive faster approval. Lead time: plan for at least one sprint of buffer between tagging a release and these marketplace listings going live.

Manual config harnesses (Claude Code’s claude mcp add, Codex CLI’s ~/.codex/config.toml) do not require a submission step — users reference npx @nowline/mcp directly, which resolves from npm.

This section is the canonical record for how Nowline MCP artifacts are named and identified across channels. It is a going-forward convention: already-published VS Code and Cursor .vsix artifacts keep their existing ids (nowline.vscode-nowline) and are not re-published or renamed to apply this convention.

Two independent levers:

  • Publishing id (stable, default nowline): every OSS MCP artifact uses the bare nowline id. The -cloud suffix is added only where the cloud artifact would collide with the OSS one in the same namespace or store (currently only the public MCP registry). The OSS id is never suffixed (no -oss).
  • Display name (phased): “Nowline” today (OSS only); when Nowline Cloud launches, OSS becomes “Nowline OSS” and the cloud “Nowline Cloud”. “Pro”/“Enterprise” are account tiers that gate Nowline Cloud, never connector or display names.

Concrete ids per channel:

ChannelOSS idCloud idNotes
npm@nowline/mcpn/a (Go server)OSS package; cloud is remote-only
Public MCP registryio.nowline/nowlineio.nowline/nowline-cloudOnly channel where both tiers share a namespace, so cloud is suffixed
Claude Desktop Extensionsname: nowline (.mcpb manifest)n/a (Connectors directory, separate store)Connectors directory = separate store; no collision
Claude Connectors Directoryn/a (Extensions directory, separate store)nowlineEach store holds only one tier
Cursor Marketplacenowline (local)nowline-cloud (remote OAuth)One store, two listings
VS Code MCP gallerynowline (local)nowline-cloud (remote OAuth)Sourced from public MCP registry
Gemini CLI extensionname: nowlineremote http entry (same channel)

Hard rules:

  • OSS stays @nowline/mcp (Apache-2.0) on npm + public MCP registry (io.nowline/nowline) + .mcpb (name: nowline).
  • The cloud artifact and any bundle that wires it are proprietary “Nowline Cloud” artifacts shipped only from the proprietary side — never from lolay/nowline.
  • Tool names stay identical across OSS and Cloud (validate, render, read, etc.); only the server display name + id differ.
  • Do NOT re-publish or rename the already-published .vsix (nowline.vscode-nowline) or Cursor extension to apply this convention. It is a going-forward-only change.

Operand-first descriptions on every listing (regardless of phase):

  • OSS: “Create and edit your roadmaps in a plain-text format.”
  • Cloud: “Reads/writes your Nowline roadmaps in the cloud. Requires a Nowline account; cloud features depend on your plan (Pro/Enterprise).”

See specs/mcp.md and specs/cli-distribution.md § MCP distribution for the full harness coverage matrix.

CHANGELOG.md follows Keep a Changelog. Two roles, one file:

  • Contributors append an entry to the ## [Unreleased] section as part of their PR. Use the existing subsections (Added, Changed, Deprecated, Removed, Fixed, Security) and link to the PR number where useful.
  • cut-release automatically moves every entry under ## [Unreleased] into a new ## [X.Y.Z] - YYYY-MM-DD section directly above the previous release, leaving an empty ## [Unreleased] skeleton for the next cycle. This is handled by .github/scripts/release-changelog.mjs (the release-changelog Make target) and runs as part of the cut-release job — no manual edit is required.

Both CHANGELOG.md (root) and packages/vscode-extension/CHANGELOG.md are promoted in the same commit.

Empty [Unreleased] section. If ## [Unreleased] has no entries when a release is cut, the script still succeeds and emits an empty ## [X.Y.Z] - YYYY-MM-DD section. A fail-on-empty pre-flight guard remains a possible future enhancement.

  • Verify the GitHub Release page lists all eight binaries / debs.
  • Verify each @nowline/* package shows the new version on npm (all 17 should return X.Y.Z):
    • npm view @nowline/core version
    • npm view @nowline/layout version
    • npm view @nowline/renderer version
    • npm view @nowline/browser version
    • npm view @nowline/embed version
    • npm view @nowline/preview-shell version
    • npm view @nowline/lsp version
    • npm view @nowline/lsp-worker version
    • npm view @nowline/export-core version
    • npm view @nowline/export-png version
    • npm view @nowline/export-pdf version
    • npm view @nowline/export-html version
    • npm view @nowline/export-mermaid version
    • npm view @nowline/export-xlsx version
    • npm view @nowline/export-msproj version
    • npm view @nowline/config version
    • npm view @nowline/cli version
    • npm view @nowline/mcp version
  • Verify Homebrew works: brew update && brew install lolay/tap/nowline && nowline --version — should print X.Y.Z (no +sha suffix on a release build).
  • Verify the VS Code extension shows the new version on the Marketplace and Open VSX.

There’s no automated rollback. If a release is broken:

  1. Open a GitHub issue describing what’s wrong.
  2. Cut a hotfix release with the next patch version (e.g. v0.1.0 → v0.1.1) via the Hotfix flow; do not delete or overwrite the broken tag.
  3. Mark the broken release as a pre-release on GitHub so package managers stop offering it.
  4. For npm-specific breakage, npm deprecate '@nowline/<pkg>@<version>' "broken release; use vX.Y.Z+1" rather than unpublishing — unpublish has a 72-hour window and breaks existing lockfiles.
  5. For Marketplace breakage, you can unpublish a version with vsce unpublish nowline.vscode-nowline@X.Y.Z. Open VSX has a similar ovsx unpublish command.