Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 32 additions & 17 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -61,19 +83,11 @@ jobs:
- name: Run release checks
run: pnpm release:check

# Publish with pnpm, not `npm publish <tarball>`. 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'
Expand All @@ -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"
14 changes: 14 additions & 0 deletions docs/MVP.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Wire Lang MVP

## Package release contract

Public packages use independent versions. A `<directory>@<version>` 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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
33 changes: 22 additions & 11 deletions scripts/package-smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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 },
);
Expand All @@ -94,7 +103,7 @@ if (!compiled.ok) {
throw new Error("compile returned fatal diagnostics");
}
const svg = renderSvg(source);
if (!svg.startsWith("<svg") || !svg.includes(${JSON.stringify(`data-wire-lang-version="${version}"`)})) {
if (!svg.startsWith("<svg") || !svg.includes(${JSON.stringify(`data-wire-lang-version="${LANGUAGE_VERSION}"`)})) {
throw new Error("renderSvg did not return the expected standalone SVG");
}
`,
Expand Down Expand Up @@ -184,8 +193,10 @@ void result;
);

const wireBin = join(consumerDir, "node_modules", ".bin", binName);
const versionResult = await run(wireBin, ["--version"], { cwd: consumerDir });
if (versionResult.stdout.trim() !== version) {
const versionResult = await run(wireBin, ["--version"], {
cwd: consumerDir,
});
if (versionResult.stdout.trim() !== versions.cli) {
throw new Error(`wire --version returned ${JSON.stringify(versionResult.stdout.trim())}`);
}

Expand Down
35 changes: 35 additions & 0 deletions scripts/release-target.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { appendFileSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

export function releaseTarget(ref, selected, realPublish, readManifest) {
const match = /^refs\/tags\/(core|cli|browser|markdown|wire-lang)@([0-9]+\.[0-9]+\.[0-9]+)$/.exec(
ref,
);
if (realPublish && !match)
throw new Error("Publishing requires a package tag such as browser@0.5.0");
const directory = match?.[1] ?? selected;
if (!["core", "cli", "browser", "markdown", "wire-lang"].includes(directory)) {
throw new Error("Select one package to release");
}
const manifest = readManifest(directory);
if (match && match[2] !== manifest.version)
throw new Error("Tag version does not match package.json");
return { directory, name: manifest.name, version: manifest.version };
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
const target = releaseTarget(
process.env.GITHUB_REF ?? "",
process.env.RELEASE_PACKAGE,
process.env.GITHUB_EVENT_NAME === "push" || process.env.RELEASE_MODE === "publish",
(directory) =>
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`,
);
}
29 changes: 29 additions & 0 deletions scripts/release-target.test.mjs
Original file line number Diff line number Diff line change
@@ -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));
});