diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index fe06904..2c4ca66 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -58,6 +58,31 @@ document in the same change (see `AGENTS.md`): - `docs/adr/` — hard-to-reverse architectural decisions. - `skills/wire-lang/` — user-facing authoring instructions and syntax guidance. +## Package releases + +Packages have independent versions. Bump only packages whose published contents +or dependency requirements changed, and record the affected package names and +versions in `CHANGELOG.md`. Do not bump every package for a browser-only change. + +After merging the version change and passing `pnpm release:check`, tag the commit +with the package directory and version, for example `browser@0.5.0`, and push that +tag. Supported prefixes are `core`, `cli`, `browser`, `markdown`, and `wire-lang`. +The release workflow validates the tag against that package's manifest and +publishes only that package. Global `v*` tags no longer trigger publishing. +Manual branch runs are dry runs with an explicit package choice; real publishes +require a matching package tag. + +Workspace dependencies resolve to each dependency's own version during packing. +Publish required dependency versions first. A core change does not automatically +release every consumer: assess which consumers need the new core version. +Browser bundles core, so delivering a core fix to browser users requires a browser +release too. The aggregate CLI reports the installed CLI package's version. +The SVG language metadata is independent of browser, CLI, and Markdown versions. + +Trusted publishers continue to use `publish.yml` and the `release` environment +for each npm package. Each tag releases one package; retry only unpublished +versions, since npm does not allow overwriting an existing version. + ## Diagnostics are a contract Diagnostic `code` strings in `packages/core/src/diagnostics.ts` are a public diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index efe38e2..a6a9282 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,9 +3,24 @@ name: Release on: push: tags: - - "v*" + - "core@*" + - "cli@*" + - "browser@*" + - "markdown@*" + - "wire-lang@*" workflow_dispatch: inputs: + package: + description: "Package for a dry run on a branch; package tags select their own target" + required: true + default: "browser" + type: choice + options: + - core + - cli + - browser + - markdown + - wire-lang mode: description: "Run a dry-run publish check, or publish to npm" required: true @@ -34,6 +49,13 @@ jobs: with: persist-credentials: false + - name: Select and validate release target + id: target + env: + RELEASE_PACKAGE: ${{ inputs.package }} + RELEASE_MODE: ${{ inputs.mode }} + run: node scripts/release-target.mjs + - name: Install pnpm uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 @@ -61,19 +83,11 @@ jobs: - name: Run release checks run: pnpm release:check - # Publish with pnpm, not `npm publish `. pnpm runs the publish - # from each package directory (rewriting workspace:* deps to real - # versions), which is what lets npm's OIDC trusted-publishing token - # exchange engage. Publishing a pre-packed tarball via `npm publish` skips - # OIDC entirely and fails with a misleading ENEEDAUTH/E404. - name: Dry-run publish - run: pnpm -r publish --dry-run --no-git-checks --access public --tag "${{ inputs.npm_tag || 'latest' }}" - - - name: Require tag for real publish - if: (github.event_name == 'push' || inputs.mode == 'publish') && !startsWith(github.ref, 'refs/tags/v') - run: | - echo "Real publish must run from a v* tag, for example refs/tags/v0.2.0." >&2 - exit 1 + env: + RELEASE_PACKAGE: ${{ steps.target.outputs.name }} + NPM_DIST_TAG: ${{ inputs.npm_tag || 'latest' }} + run: pnpm --filter "$RELEASE_PACKAGE" publish --dry-run --no-git-checks --access public --tag "$NPM_DIST_TAG" - name: Verify trusted publishing environment if: github.event_name == 'push' || inputs.mode == 'publish' @@ -95,10 +109,11 @@ jobs: # OIDC token exchange engage; npm attaches provenance automatically. - name: Publish to npm if: github.event_name == 'push' || inputs.mode == 'publish' + env: + RELEASE_DIRECTORY: ${{ steps.target.outputs.directory }} + NPM_DIST_TAG: ${{ inputs.npm_tag || 'latest' }} run: | set -euo pipefail node scripts/pin-workspace-deps.mjs - tag="${{ inputs.npm_tag || 'latest' }}" - for pkg in core cli browser markdown wire-lang; do - ( cd "packages/$pkg" && npm publish --provenance --access public --tag "$tag" ) - done + cd "packages/$RELEASE_DIRECTORY" + npm publish --provenance --access public --tag "$NPM_DIST_TAG" diff --git a/docs/MVP.md b/docs/MVP.md index f3146d0..37c988c 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -1,5 +1,19 @@ # Wire Lang MVP +## Package release contract + +Public packages use independent versions. A `@` tag selects +exactly one package for publication and must match its package manifest. Supported +directories are `core`, `cli`, `browser`, `markdown`, and `wire-lang`. Global `v*` +tags are historical and no longer trigger releases. Manual publication also +requires a package tag; branch runs can only perform a selected-package dry run. +All repository release checks still run before publishing. Workspace dependencies +are pinned to their own local versions, which must be published before consumers. +Browser embeds core and needs its own release to distribute core changes. Package +versions need not match language metadata or the installed CLI version. + +See `.github/CONTRIBUTING.md` for the release procedure. + This document defines the first useful version of Wire Lang: a JavaScript/TypeScript library and minimal developer CLI for turning textual electronic schematic descriptions into readable SVG diagrams. Wire Lang is not a breadboard tool, PCB layout tool, simulator, BOM manager, or visual editor in the MVP. It is a documentation-oriented schematic renderer with strong authoring feedback for humans, editors, and AI agents. diff --git a/package.json b/package.json index 34c973a..c4d3fd3 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "skill:publish": "skills add eduardozf/wire-lang --skill wire-lang --agent codex --global --copy --yes", "pack:dry-run": "pnpm --filter @wire-lang/core pack --dry-run && pnpm --filter @wire-lang/cli pack --dry-run && pnpm --filter @wire-lang/browser pack --dry-run && pnpm --filter @wire-lang/markdown pack --dry-run && pnpm --filter wire-lang pack --dry-run", "package:smoke": "node scripts/package-smoke-test.mjs", - "release:check": "pnpm lint && pnpm docs:check && pnpm typecheck && pnpm test && pnpm build && pnpm audit --prod && pnpm pack:dry-run && pnpm package:smoke", + "release:check": "node --test scripts/release-target.test.mjs && pnpm lint && pnpm docs:check && pnpm typecheck && pnpm test && pnpm build && pnpm audit --prod && pnpm pack:dry-run && pnpm package:smoke", "examples:update": "node scripts/update-examples.mjs", "bench": "node tools/symbol-bench/server.mjs", "clean": "tsc -b --clean && rimraf packages/*/dist packages/*/*.tsbuildinfo" diff --git a/scripts/package-smoke-test.mjs b/scripts/package-smoke-test.mjs index 348664c..8e80cc8 100644 --- a/scripts/package-smoke-test.mjs +++ b/scripts/package-smoke-test.mjs @@ -43,10 +43,19 @@ function tarball(packDir, fileName) { } async function main() { - const packageJson = JSON.parse( - await readFile(new URL("../packages/wire-lang/package.json", import.meta.url), "utf8"), + const versions = {}; + for (const directory of ["core", "cli", "browser", "markdown", "wire-lang"]) { + const manifest = JSON.parse( + await readFile(new URL(`../packages/${directory}/package.json`, import.meta.url), "utf8"), + ); + versions[directory] = manifest.version; + } + const modelSource = await readFile( + new URL("../packages/core/src/model/types.ts", import.meta.url), + "utf8", ); - const version = packageJson.version; + const LANGUAGE_VERSION = /export const LANGUAGE_VERSION = "([^"]+)"/.exec(modelSource)?.[1]; + if (!LANGUAGE_VERSION) throw new Error("Language version declaration missing"); const workspace = await mkdtemp(join(tmpdir(), "wire-lang-package-smoke-")); const packDir = join(workspace, "tarballs"); const consumerDir = join(workspace, "consumer"); @@ -66,11 +75,11 @@ async function main() { "install", "--no-audit", "--no-fund", - tarball(packDir, `wire-lang-core-${version}.tgz`), - tarball(packDir, `wire-lang-cli-${version}.tgz`), - tarball(packDir, `wire-lang-markdown-${version}.tgz`), - tarball(packDir, `wire-lang-browser-${version}.tgz`), - tarball(packDir, `wire-lang-${version}.tgz`), + tarball(packDir, `wire-lang-core-${versions.core}.tgz`), + tarball(packDir, `wire-lang-cli-${versions.cli}.tgz`), + tarball(packDir, `wire-lang-markdown-${versions.markdown}.tgz`), + tarball(packDir, `wire-lang-browser-${versions.browser}.tgz`), + tarball(packDir, `wire-lang-${versions["wire-lang"]}.tgz`), ], { cwd: consumerDir }, ); @@ -94,7 +103,7 @@ if (!compiled.ok) { throw new Error("compile returned fatal diagnostics"); } const svg = renderSvg(source); -if (!svg.startsWith(" + JSON.parse( + readFileSync(new URL(`../packages/${directory}/package.json`, import.meta.url), "utf8"), + ), + ); + console.log(`Release target: ${target.name}@${target.version}`); + appendFileSync( + process.env.GITHUB_OUTPUT, + `directory=${target.directory}\nname=${target.name}\nversion=${target.version}\n`, + ); +} diff --git a/scripts/release-target.test.mjs b/scripts/release-target.test.mjs new file mode 100644 index 0000000..bbcc854 --- /dev/null +++ b/scripts/release-target.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { releaseTarget } from "./release-target.mjs"; + +const manifest = (directory) => ({ + name: `@wire-lang/${directory}`, + version: "0.5.0", +}); +test("a browser tag selects only browser", () => { + assert.deepEqual(releaseTarget("refs/tags/browser@0.5.0", "core", true, manifest), { + directory: "browser", + name: "@wire-lang/browser", + version: "0.5.0", + }); +}); +test("manual dry runs select one package without a tag", () => { + assert.equal(releaseTarget("refs/heads/main", "markdown", false, manifest).directory, "markdown"); +}); +test("rejects global tags, branches, unknown packages and mismatched versions", () => { + for (const ref of [ + "refs/tags/v0.5.0", + "refs/heads/main", + "refs/tags/unknown@0.5.0", + "refs/tags/browser@0.6.0", + ]) { + assert.throws(() => releaseTarget(ref, "browser", true, manifest)); + } + assert.throws(() => releaseTarget("refs/heads/main", "../core", false, manifest)); +});