diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index adf4b78..c298109 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,11 +1,11 @@
name: ci
on:
- push:
- branches: [main]
pull_request:
# Callable so the release workflow gates publishing on this exact suite
- # rather than duplicating (or skipping) it.
+ # rather than duplicating (or skipping) it. Pushes to main are covered that
+ # way — release.yml runs this suite on every merge — so there is no `push`
+ # trigger here, which would run the whole suite twice per merge.
workflow_call:
jobs:
@@ -38,6 +38,9 @@ jobs:
- name: MusicDSL conformance (Python reference)
run: pytest conformance/music-dsl/runners/python -q
+ - name: Release surface tests
+ run: pytest scripts/tests -q
+
# Informational coverage signal (not a gate). Uses the pytest binary, not
# `python -m pytest` — the latter puts the repo root on sys.path[0], where
# the tonalis/ source dir shadows the installed package.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 47e756d..515a0b1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,61 +2,111 @@ name: release
# Publishes the six artifacts — the theory lib (PyPI/crates: tonalis-music-dsl, npm: @tonalis/music-dsl) + tonalis, each to PyPI / npm / crates.io —
# via OIDC trusted publishing. No stored tokens: each job mints a short-lived,
-# workflow-scoped credential from the registry.
+# workflow-scoped credential from the registry. This is the ONLY workflow that
+# publishes; nothing is ever published from a laptop.
#
-# Prerequisites (one-time, human, in each registry UI) BEFORE the first tag:
-# - PyPI: configure a Trusted Publisher for tonalis-music-dsl and for tonalis
-# (owner=drycode, repo=tonalis, workflow=release.yml).
-# - npm: configure a Trusted Publisher for each package (same coordinates).
-# - crates.io: configure a Trusted Publisher for each crate (same coordinates).
-# If a publisher is not configured, that package's job fails — the others are unaffected.
+# Trigger: every push to main, which means every merged pull request ships a release.
+# The version comes from the repository, not from a tag: version-gate.yml has already
+# proven on the pull request that it is a legal single step and that no registry has
+# claimed it. The tag is created at the end as a record, not consumed as the trigger.
#
-# Gating: nothing publishes until two jobs pass — `ci` (the full test suite, reused from
-# ci.yml via workflow_call) and `guard` (the pushed tag matches all six manifest versions).
-# The three base-library publish jobs `needs: [ci, guard]`; the dependent jobs inherit the
-# gate transitively through their library dependency. PyPI/npm/crates versions are immutable,
-# so a red suite or a mismatched tag must fail before any artifact is built.
+# Prerequisites (one-time, human, in each registry UI):
+# - PyPI: a Trusted Publisher for tonalis-music-dsl and for tonalis
+# (owner=drycode, repo=tonalis, workflow=release.yml, no environment).
+# - npm: a Trusted Publisher for each package (same coordinates).
+# - crates.io: a Trusted Publisher for each crate (same coordinates).
+# If a publisher is not configured, that package's job fails — the others are
+# unaffected, and rerunning the run publishes only what is still missing.
#
-# Ordering: tonalis depends on the music-dsl library in all three ecosystems, so the base library
-# publishes first and the dependent job `needs:` it. (crates.io: modern `cargo publish`
-# blocks until the new version is in the index, so the dependent resolve is race-free.)
+# Gating: nothing publishes until `ci` (the full suite, reused from ci.yml via
+# workflow_call) and `guard` (the sixteen authored version locations agree) both pass.
+#
+# Idempotence: `guard` asks each registry whether this version already exists and
+# emits one flag per artifact. An artifact that is already published is skipped, so
+# rerunning a partially failed release completes it instead of failing on duplicate
+# versions, and a push to main that carries no version bump is a clean no-op.
+#
+# Ordering: tonalis depends on the music-dsl library in all three ecosystems, so the
+# base library publishes first and the dependent job `needs:` it. (crates.io: modern
+# `cargo publish` blocks until the new version is in the index, so the dependent
+# resolve is race-free.) A dependent job tolerates a *skipped* base library — that
+# means the base was already published — but never a failed one, and it re-asserts
+# the `ci` and `guard` gates that `!cancelled()` would otherwise let it bypass.
#
# Note: npm --provenance is intentionally NOT used here. Provenance requires a public
-# source repo, but this workflow first runs while drycode/tonalis is still private
-# (publish → verify → then flip public). Enable --provenance in a later release once the
-# repo is public.
+# source repo, but this workflow first ran while drycode/tonalis was still private
+# (publish → verify → then flip public). Enable --provenance in a later release.
on:
push:
- tags:
- - 'v*'
+ branches: [main]
permissions:
contents: read
+# One release at a time. Cancellation is disabled: a queued release must run, not
+# be discarded, or its version would never be published.
+concurrency:
+ group: tonalis-release
+ cancel-in-progress: false
+
jobs:
# ---------- gates ----------
# Full test suite (Python/TS/Rust units + conformance + 3-way differential fuzzer),
- # reused verbatim from ci.yml. A tag can point at any commit, so re-run it here rather
- # than trusting that CI happened to pass on this SHA.
+ # reused verbatim from ci.yml. This is the only run of it for a merge commit: ci.yml
+ # itself no longer triggers on pushes to main.
ci:
uses: ./.github/workflows/ci.yml
- # The tag must equal the version in all six manifests, and they must agree with each
- # other. Logic + local tests: scripts/check_release_version.py.
+ # The sixteen authored version locations must agree with each other, and each
+ # registry is asked whether it already has this version.
+ # Logic + tests: scripts/release_surface.py, scripts/tests/test_release_scripts.py.
guard:
runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.version.outputs.version }}
+ any_pending: ${{ steps.status.outputs.any_pending }}
+ publish_pypi_music_dsl: ${{ steps.status.outputs.publish_pypi_music_dsl }}
+ publish_pypi_tonalis: ${{ steps.status.outputs.publish_pypi_tonalis }}
+ publish_npm_music_dsl: ${{ steps.status.outputs.publish_npm_music_dsl }}
+ publish_npm_tonalis: ${{ steps.status.outputs.publish_npm_tonalis }}
+ publish_crates_music_dsl: ${{ steps.status.outputs.publish_crates_music_dsl }}
+ publish_crates_tonalis: ${{ steps.status.outputs.publish_crates_tonalis }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- - name: Tag matches all six manifest versions
- run: python scripts/check_release_version.py "$GITHUB_REF_NAME"
+
+ - name: Every authored location agrees on the version
+ run: python scripts/check_release_version.py
+
+ - name: Resolve the version
+ id: version
+ run: echo "version=$(python scripts/check_release_version.py --print)" >> "$GITHUB_OUTPUT"
+
+ - name: Ask each registry what is already published
+ id: status
+ run: |
+ python scripts/registry_status.py \
+ "${{ steps.version.outputs.version }}" --github-output
+
+ - name: Summary
+ run: |
+ {
+ echo "### Release ${{ steps.version.outputs.version }}"
+ echo
+ if [ "${{ steps.status.outputs.any_pending }}" = "true" ]; then
+ echo "Publishing the artifacts still missing from their registries."
+ else
+ echo "Already published everywhere — nothing to do."
+ fi
+ } >> "$GITHUB_STEP_SUMMARY"
# ---------- PyPI ----------
pypi-music-dsl:
needs: [ci, guard]
+ if: ${{ needs.guard.outputs.publish_pypi_music_dsl == 'true' }}
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC
@@ -73,7 +123,13 @@ jobs:
packages-dir: music-dsl/python/dist
pypi-tonalis:
- needs: pypi-music-dsl
+ needs: [ci, guard, pypi-music-dsl]
+ if: >-
+ ${{ !cancelled()
+ && needs.ci.result == 'success'
+ && needs.guard.result == 'success'
+ && needs.guard.outputs.publish_pypi_tonalis == 'true'
+ && needs['pypi-music-dsl'].result != 'failure' }}
runs-on: ubuntu-latest
permissions:
id-token: write
@@ -92,6 +148,7 @@ jobs:
# ---------- npm ----------
npm-music-dsl:
needs: [ci, guard]
+ if: ${{ needs.guard.outputs.publish_npm_music_dsl == 'true' }}
runs-on: ubuntu-latest
permissions:
id-token: write
@@ -108,7 +165,13 @@ jobs:
working-directory: music-dsl/ts
npm-tonalis:
- needs: npm-music-dsl
+ needs: [ci, guard, npm-music-dsl]
+ if: >-
+ ${{ !cancelled()
+ && needs.ci.result == 'success'
+ && needs.guard.result == 'success'
+ && needs.guard.outputs.publish_npm_tonalis == 'true'
+ && needs['npm-music-dsl'].result != 'failure' }}
runs-on: ubuntu-latest
permissions:
id-token: write
@@ -125,9 +188,9 @@ jobs:
working-directory: music-dsl/ts
- run: npm ci
working-directory: tonalis/ts
- # Approach A: the repo keeps a local `file:` link to @tonalis/music-dsl for monorepo dev;
- # rewrite it to the published version range only in the publish artifact.
- - run: npm pkg set 'dependencies[@tonalis/music-dsl]=^0.1.1'
+ # The repo keeps a local `file:` link to @tonalis/music-dsl for monorepo dev;
+ # rewrite it to this release's published range, in the publish artifact only.
+ - run: npm pkg set "dependencies[@tonalis/music-dsl]=^${{ needs.guard.outputs.version }}"
working-directory: tonalis/ts
- run: npm publish --access public
working-directory: tonalis/ts
@@ -135,6 +198,7 @@ jobs:
# ---------- crates.io ----------
crates-music-dsl:
needs: [ci, guard]
+ if: ${{ needs.guard.outputs.publish_crates_music_dsl == 'true' }}
runs-on: ubuntu-latest
permissions:
id-token: write
@@ -149,7 +213,13 @@ jobs:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
crates-tonalis:
- needs: crates-music-dsl
+ needs: [ci, guard, crates-music-dsl]
+ if: >-
+ ${{ !cancelled()
+ && needs.ci.result == 'success'
+ && needs.guard.result == 'success'
+ && needs.guard.outputs.publish_crates_tonalis == 'true'
+ && needs['crates-music-dsl'].result != 'failure' }}
runs-on: ubuntu-latest
permissions:
id-token: write
@@ -162,3 +232,46 @@ jobs:
working-directory: tonalis/rust
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
+
+ # ---------- record ----------
+ # Proves the release actually landed in all six registries, then tags it. Asking
+ # the registries is stronger than reading job results: a skipped dependent job is
+ # indistinguishable from a successful one without this. If anything is missing this
+ # job fails and no tag is written — rerun the run to publish the remainder.
+ #
+ # A GITHUB_TOKEN-created tag does not trigger workflows, and this workflow no longer
+ # listens for tags, so there is no recursion.
+ record:
+ needs: [ci, guard, pypi-tonalis, npm-tonalis, crates-tonalis]
+ if: >-
+ ${{ !cancelled()
+ && needs.ci.result == 'success'
+ && needs.guard.result == 'success'
+ && needs.guard.outputs.any_pending == 'true' }}
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v7
+ with:
+ python-version: '3.12'
+
+ - name: All six artifacts are published
+ run: |
+ python scripts/registry_status.py \
+ "${{ needs.guard.outputs.version }}" --require-present --retries 6 --delay 20
+
+ - name: Tag the release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ VERSION: ${{ needs.guard.outputs.version }}
+ run: |
+ if gh release view "v$VERSION" >/dev/null 2>&1; then
+ echo "v$VERSION is already recorded"
+ exit 0
+ fi
+ gh release create "v$VERSION" \
+ --target "$GITHUB_SHA" \
+ --title "v$VERSION" \
+ --generate-notes
diff --git a/.github/workflows/version-gate.yml b/.github/workflows/version-gate.yml
new file mode 100644
index 0000000..70f375f
--- /dev/null
+++ b/.github/workflows/version-gate.yml
@@ -0,0 +1,66 @@
+name: version-gate
+
+# Every merge to main publishes a release (see release.yml), so every pull request
+# must declare the version it ships. This is the referee: the committer owns the
+# number, and nothing merges unless it is a legal, unclaimed, single step forward.
+#
+# Bump with `python scripts/set_version.py --bump patch|minor|major`, which rewrites
+# all sixteen authored locations at once. See RELEASING.md.
+#
+# Break glass: label the pull request `release:override` to skip the "previous
+# release is complete" check. That check exists to stop a version gap, but it would
+# otherwise deadlock a pull request that fixes a broken release.
+
+on:
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: version-gate-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ version:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: actions/setup-python@v7
+ with:
+ python-version: '3.12'
+
+ # The bump is measured against the base branch, which the default shallow
+ # pull request checkout does not fetch.
+ - name: Fetch the base branch
+ run: |
+ git fetch --depth=1 origin \
+ "+refs/heads/${GITHUB_BASE_REF}:refs/remotes/origin/${GITHUB_BASE_REF}"
+
+ - name: Every authored location agrees on the version
+ run: python scripts/check_release_version.py
+
+ - name: The version steps exactly once over the base branch
+ id: bump
+ run: python scripts/check_version_bump.py "origin/${GITHUB_BASE_REF}" --github-output
+
+ - name: The previous release is complete
+ if: ${{ !contains(github.event.pull_request.labels.*.name, 'release:override') }}
+ run: python scripts/registry_status.py "${{ steps.bump.outputs.previous }}" --require-present
+
+ - name: The declared version is unclaimed
+ run: python scripts/registry_status.py "${{ steps.bump.outputs.version }}" --require-absent
+
+ - name: Summary
+ run: |
+ {
+ echo "### Release on merge"
+ echo
+ echo "\`${{ steps.bump.outputs.previous }}\` → \`${{ steps.bump.outputs.version }}\`" \
+ "(**${{ steps.bump.outputs.bump }}**)"
+ echo
+ echo "Merging this pull request publishes all six artifacts at" \
+ "\`${{ steps.bump.outputs.version }}\`."
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/RELEASING.md b/RELEASING.md
new file mode 100644
index 0000000..0a06923
--- /dev/null
+++ b/RELEASING.md
@@ -0,0 +1,92 @@
+# Releasing
+
+**Every pull request merged to `main` publishes a release.** There is no separate
+release step, no release branch, and no manual publishing. The committer declares
+the version; CI verifies it and does the publishing.
+
+Six artifacts ship from one version:
+
+| | PyPI | npm | crates.io |
+| -- | -- | -- | -- |
+| theory library | `tonalis-music-dsl` | `@tonalis/music-dsl` | `tonalis-music-dsl` |
+| lead-sheet DSL | `tonalis` | `tonalis` | `tonalis` |
+
+## Making a change
+
+Bump the version as part of your pull request:
+
+```bash
+python scripts/set_version.py --bump patch # or minor, or major
+```
+
+That rewrites all sixteen authored locations — six package manifests, the two
+internal pins, and eight lockfile entries — so nothing is left behind. Commit the
+result with the rest of your change.
+
+Choose the bump by what the change does to the public surface of any port:
+
+- **patch** — a fix or an internal change; no surface change.
+- **minor** — new surface, existing surface unchanged.
+- **major** — existing surface changed or removed.
+
+Because every merge ships, there is no "no release" option. A documentation-only
+change ships a patch release.
+
+## What CI enforces
+
+`version-gate.yml` runs on every pull request and fails unless:
+
+1. All sixteen locations agree on one version.
+2. The version is exactly one SemVer step from `main` — `PATCH+1`, `MINOR+1` with
+ patch reset, or `MAJOR+1` with both reset. A skip like `0.1.1` → `0.1.5` is a
+ typo, not an intent.
+3. No registry has already published that version. Versions are immutable.
+4. The version currently on `main` is published in all six registries, so a new
+ release never stacks on a half-published one.
+
+## What happens on merge
+
+`release.yml` runs on the push to `main`:
+
+1. `ci` — the full Python/TypeScript/Rust suite, conformance runners, and the
+ three-way differential fuzzer.
+2. `guard` — re-checks the version surface and asks each registry what already
+ exists.
+3. Six publish jobs, each skipped if that artifact is already published, using
+ OIDC trusted publishing. The theory library goes first; the DSL follows.
+4. `record` — confirms all six artifacts are live, then creates tag `vX.Y.Z` and a
+ GitHub Release with generated notes.
+
+## When a release partially fails
+
+Publishing is idempotent, so **rerun the failed release run**. Each publish job
+skips whatever already landed and retries only what is missing. Do this before
+merging another pull request — the gate will otherwise block the next one, which
+is the point.
+
+If the repair itself needs a code change, label that pull request
+`release:override` to bypass the "previous release is complete" check.
+
+## Registry prerequisites
+
+Each registry needs a one-time Trusted Publisher, configured in its own UI, for
+both packages:
+
+```text
+Owner / organization: drycode
+Repository: tonalis
+Workflow: release.yml
+Environment: (leave blank)
+```
+
+- PyPI —
+ and the same page for `tonalis`.
+- npm — package settings → Trusted Publisher.
+- crates.io — `https://crates.io/crates//settings` → Trusted Publishing.
+
+Without a publisher, that one job fails and the rest are unaffected; add the
+publisher and rerun the run.
+
+## Design
+
+`docs/specs/2026-08-19-release-on-merge-design.md`.
diff --git a/docs/specs/2026-08-19-release-on-merge-design.md b/docs/specs/2026-08-19-release-on-merge-design.md
new file mode 100644
index 0000000..23e83cf
--- /dev/null
+++ b/docs/specs/2026-08-19-release-on-merge-design.md
@@ -0,0 +1,139 @@
+# Release on merge — committer-declared version, CI-verified
+
+## Context
+
+Tonalis publishes six artifacts from one version: the theory library
+(`tonalis-music-dsl` on PyPI/crates.io, `@tonalis/music-dsl` on npm) and the DSL
+(`tonalis` on all three). Until now `release.yml` fired on a pushed `v*` tag and
+a human bumped sixteen version locations by hand, tagged, and hoped. The 0.1.1
+release exposed three defects in that model:
+
+1. **Nothing forced a release.** A merge published nothing; the tag was a
+ separate manual act that could be skipped or forgotten.
+2. **A partial release could not be repaired.** When PyPI/npm Trusted Publishers
+ were missing, three of six jobs failed. Rerunning them re-attempted every
+ artifact, including the already-published crates, which fail on duplicate
+ versions. Recovery was manual `cargo publish` from a laptop — exactly the
+ stored-credential path OIDC exists to eliminate.
+3. **A hardcoded pin drifted.** `release.yml` rewrote the npm dependency range
+ to a literal `^0.1.1`, so the next release would have shipped `tonalis`
+ depending on a stale theory library.
+
+## Requirements
+
+- Every pull request merged to `main` produces a semantic release.
+- The committer declares the version; CI verifies it and refuses anything else.
+- `release.yml` is the only workflow that publishes. No laptop publishing.
+- A partially failed release is repairable by rerunning it.
+
+## Approach
+
+**The committer owns the version. CI is the referee.** A pull request that does
+not carry a legal version increment cannot merge; a merge that does publishes
+automatically.
+
+Rejected alternatives:
+
+- **`semantic-release` over Conventional Commits** — requires replacing the
+ established `[topic] Summary` title convention and infers intent from prose.
+- **Release Please** — interposes a second release pull request, so a merge does
+ not release; it queues a request to release.
+- **CI-authored version-bump commit** — needs a bot identity with write access
+ to `main` and turns every merge into two commits.
+
+### The version surface
+
+Sixteen authored locations carry the version. `scripts/release_surface.py` is
+their single registry, and every other script and workflow reads it:
+
+| group | count | locations |
+| -- | -- | -- |
+| package manifests | 6 | `{tonalis,music-dsl}/{python/pyproject.toml,rust/Cargo.toml,ts/package.json}` |
+| internal pins | 2 | `tonalis/python` → `tonalis-music-dsl==X.Y.Z`; `tonalis/rust` → `music_dsl` dependency `version` |
+| lockfiles | 8 | both `Cargo.lock` packages, both `package-lock.json` roots, and the `tonalis/ts` linked-dependency entry |
+
+`scripts/set_version.py X.Y.Z` rewrites all sixteen deterministically, so the
+committer runs one command rather than editing four file formats by hand.
+
+### Pull request gate — `version-gate.yml`
+
+Required check on every pull request into `main`. It fails unless:
+
+1. All sixteen locations agree with each other.
+2. The version is a **single legal SemVer step** from the base branch: exactly
+ one of `PATCH+1`, `MINOR+1` with patch reset, or `MAJOR+1` with both reset.
+ A skip (`0.1.1` → `0.1.5`), a downgrade, an unchanged version, or a
+ pre-release suffix all fail. There is no "no release" escape: every merge
+ ships, so every pull request bumps.
+3. The declared version is absent from all six registries — versions are
+ immutable, so a collision must fail before merge, not mid-publish.
+4. The version currently on `main` is present in all six registries. This
+ refuses to stack a new release on top of a half-published one, which is the
+ failure the 0.1.1 release actually hit.
+
+Check 4 could deadlock a pull request whose purpose is to repair a broken
+release, so it — and only it — is skipped when the pull request carries the
+`release:override` label. Failing closed with one auditable escape beats failing
+open.
+
+### Release — `release.yml`
+
+Trigger changes from `push: tags: v*` to `push: branches: [main]`. The tag stops
+being the trigger and becomes the record.
+
+- `ci` — the full Python/TypeScript/Rust suite, conformance runners, and the
+ three-way differential fuzzer, reused verbatim via `workflow_call`.
+- `guard` — re-verifies the sixteen locations agree, then queries the six
+ registries and emits one boolean per artifact.
+- Six publish jobs, each `if:` its own guard flag. An artifact already present
+ at this version is skipped, not retried, so **rerunning a partially failed
+ release completes it** and a merge that carries no bump is a clean no-op
+ instead of six duplicate-version failures. Dependent jobs tolerate a skipped
+ base library via `!cancelled()` plus an explicit non-failure check, since a
+ skipped `needs` would otherwise cascade. `!cancelled()` also drops the implicit
+ success requirement on `needs`, so those jobs re-assert `ci` and `guard`
+ explicitly rather than inheriting a gate they no longer have.
+- The npm dependency range is derived from the manifest at publish time, killing
+ the hardcoded `^0.1.1`.
+- `record` — asks the registries whether all six artifacts are live, then creates
+ tag `vX.Y.Z` and a GitHub Release with generated notes, both idempotently.
+ Interrogating the registries is deliberate: a skipped dependent job is
+ indistinguishable from a successful one by job result alone, so only the
+ registries can prove the release landed. A missing artifact fails this job and
+ writes no tag. `GITHUB_TOKEN`-created tags do not trigger workflows, and
+ `release.yml` no longer listens for tags, so there is no recursion.
+
+`release.yml` uses the `tonalis-release` concurrency group with cancellation
+disabled, so two merges cannot publish concurrently and a queued release is never
+discarded. The gate uses a per-pull-request group instead — serialising it behind
+releases would let GitHub cancel a queued gate run.
+
+`ci.yml` loses its `push: branches: [main]` trigger. `release.yml` now runs that
+exact suite on every merge, so keeping the trigger would run it twice.
+
+## Testing
+
+`scripts/tests/test_release_scripts.py` covers the logic that has no other
+guard, and runs in the `ci` Python job:
+
+- Every SemVer step is classified, and skips, downgrades, equality, and
+ pre-release suffixes are rejected.
+- `set_version.py` rewrites all sixteen locations and leaves the tree agreeing
+ with itself.
+- Rewriting the current version is byte-identical, proving the JSON and TOML
+ writers preserve formatting rather than reflowing generated lockfiles.
+- Registry URL construction, including the npm scoped-name escape, and the
+ 200/404 presence mapping. An unexpected status raises instead of guessing
+ "absent" — guessing would publish over a real release.
+
+End-to-end proof is this change itself: it bumps to `0.1.2`, so merging it
+exercises the gate and the new release path against live registries.
+
+## Consequences
+
+- Every merge consumes a version number. Documentation-only changes ship a patch
+ release. This is the accepted cost of "every merge releases".
+- A rapid double merge can have GitHub cancel the queued intermediate release.
+ Recovery is rerunning that run, which the idempotent publish jobs make safe.
+- crates.io Trusted Publishing must be configured for both crates before the
+ next release, since manual publishing is now closed off.
diff --git a/mkdocs.yml b/mkdocs.yml
index 86000eb..f19ade1 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -52,10 +52,12 @@ markdown_extensions:
- toc:
permalink: true
-# Keep build tooling out of the published site (they live in docs/ for locality).
+# Keep build tooling and internal design docs out of the published site (they live
+# in docs/ for locality).
exclude_docs: |
requirements.txt
build_api.sh
+ specs/
nav:
- Home: index.md
diff --git a/music-dsl/python/pyproject.toml b/music-dsl/python/pyproject.toml
index 3de32fe..ba557a4 100644
--- a/music-dsl/python/pyproject.toml
+++ b/music-dsl/python/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tonalis-music-dsl"
-version = "0.1.1"
+version = "0.1.2"
description = "Music-theory domain: notes, chords, intervals, scales, harmonic function."
requires-python = ">=3.11"
readme = "README.md"
diff --git a/music-dsl/rust/Cargo.lock b/music-dsl/rust/Cargo.lock
index 4d7dbd5..3a721eb 100644
--- a/music-dsl/rust/Cargo.lock
+++ b/music-dsl/rust/Cargo.lock
@@ -149,7 +149,7 @@ dependencies = [
[[package]]
name = "tonalis-music-dsl"
-version = "0.1.1"
+version = "0.1.2"
dependencies = [
"regex",
"serde",
diff --git a/music-dsl/rust/Cargo.toml b/music-dsl/rust/Cargo.toml
index 2a6411c..62d39f8 100644
--- a/music-dsl/rust/Cargo.toml
+++ b/music-dsl/rust/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "tonalis-music-dsl"
-version = "0.1.1"
+version = "0.1.2"
edition = "2021"
description = "MusicDSL — music-theory domain (notes, intervals, scale-degrees, chords, encoding). Port of the Python music_dsl reference."
readme = "README.md"
diff --git a/music-dsl/ts/package-lock.json b/music-dsl/ts/package-lock.json
index fe9cca5..9b6168e 100644
--- a/music-dsl/ts/package-lock.json
+++ b/music-dsl/ts/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@tonalis/music-dsl",
- "version": "0.1.1",
+ "version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@tonalis/music-dsl",
- "version": "0.1.1",
+ "version": "0.1.2",
"license": "MIT",
"devDependencies": {
"@types/node": "^22.10.2",
diff --git a/music-dsl/ts/package.json b/music-dsl/ts/package.json
index 14d7785..6f7e99b 100644
--- a/music-dsl/ts/package.json
+++ b/music-dsl/ts/package.json
@@ -1,6 +1,6 @@
{
"name": "@tonalis/music-dsl",
- "version": "0.1.1",
+ "version": "0.1.2",
"description": "MusicDSL — music-theory domain (notes, intervals, scale-degrees, chords, encoding). Port of the Python music_dsl reference. Zero runtime deps.",
"type": "module",
"main": "./dist/index.js",
diff --git a/scripts/check_release_version.py b/scripts/check_release_version.py
index 80352f8..eafebe7 100755
--- a/scripts/check_release_version.py
+++ b/scripts/check_release_version.py
@@ -1,72 +1,59 @@
#!/usr/bin/env python3
-"""Assert every published manifest agrees on version — and matches the release tag.
+"""Assert every authored location agrees on the version.
-Six artifacts (tonalis + tonalis-music-dsl, each in Python/Rust/TS) carry a
-hand-synced version across three file formats. A tag that disagrees with any of
-them means a partial or mismatched publish to registries where versions are
-immutable. The release workflow runs this in a guard job before anything builds.
+Six artifacts carry one hand-synced version across four file formats, plus two
+internal pins and four committed lockfiles. Disagreement means a partial or
+mismatched publish to registries where versions are immutable, so both the pull
+request gate and the release guard run this before anything builds.
Usage:
- check_release_version.py # assert the six manifests agree
- check_release_version.py v0.1.1 # also assert they equal this tag/version
+ check_release_version.py # assert the surface agrees
+ check_release_version.py 0.1.2 # also assert it equals this version
+ check_release_version.py --print # print the agreed version, nothing else
Exit 0 on agreement (and match, if a version was given); 1 otherwise.
"""
from __future__ import annotations
-import json
import sys
-import tomllib
-from pathlib import Path
-ROOT = Path(__file__).resolve().parent.parent
-
-# path -> (format, key path to the version string)
-MANIFESTS: dict[str, tuple[str, tuple[str, ...]]] = {
- "tonalis/python/pyproject.toml": ("toml", ("project", "version")),
- "music-dsl/python/pyproject.toml": ("toml", ("project", "version")),
- "tonalis/rust/Cargo.toml": ("toml", ("package", "version")),
- "music-dsl/rust/Cargo.toml": ("toml", ("package", "version")),
- "tonalis/ts/package.json": ("json", ("version",)),
- "music-dsl/ts/package.json": ("json", ("version",)),
-}
-
-
-def read_version(rel: str, kind: str, keypath: tuple[str, ...]) -> str:
- text = (ROOT / rel).read_text()
- obj = tomllib.loads(text) if kind == "toml" else json.loads(text)
- for key in keypath:
- obj = obj[key]
- if not isinstance(obj, str):
- raise TypeError(f"{rel}: version is {type(obj).__name__}, not a string")
- return obj
-
-
-def manifest_versions() -> dict[str, str]:
- return {rel: read_version(rel, kind, kp) for rel, (kind, kp) in MANIFESTS.items()}
+from release_surface import SLOTS, read_surface
def main(argv: list[str]) -> int:
- expected = argv[1].lstrip("v") if len(argv) > 1 else None
- versions = manifest_versions()
-
- for rel, v in versions.items():
- mark = "" if expected is None else (" ✓" if v == expected else " ✗ MISMATCH")
- print(f"{v:<10} {rel}{mark}")
+ args = [arg for arg in argv[1:] if arg != "--print"]
+ quiet = "--print" in argv[1:]
+ expected = args[0].lstrip("v") if args else None
+
+ versions = read_surface()
+ distinct = sorted(set(versions.values()))
+
+ if not quiet:
+ for group in ("manifest", "pin", "lock"):
+ print(f"-- {group}")
+ for slot in SLOTS:
+ if slot.group != group:
+ continue
+ found = versions[slot.name]
+ mark = "" if expected is None else (" ok" if found == expected else " MISMATCH")
+ print(f" {found:<10} {slot.name}{mark}")
- distinct = set(versions.values())
if len(distinct) != 1:
- print(f"error: manifests disagree on version: {sorted(distinct)}", file=sys.stderr)
+ print(f"error: release surface disagrees on version: {distinct}", file=sys.stderr)
+ print("hint: run scripts/set_version.py to rewrite every location", file=sys.stderr)
return 1
- only = distinct.pop()
+ only = distinct[0]
if expected is not None and only != expected:
print(
- f"error: tag version {expected!r} does not match manifest version {only!r}",
+ f"error: expected version {expected!r} but the surface says {only!r}",
file=sys.stderr,
)
return 1
+
+ if quiet:
+ print(only)
return 0
diff --git a/scripts/check_version_bump.py b/scripts/check_version_bump.py
new file mode 100755
index 0000000..f90c4ea
--- /dev/null
+++ b/scripts/check_version_bump.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""Assert this branch steps the version exactly once over its base branch.
+
+Every merge to main publishes a release, so every pull request must declare a new
+version, and it must be reachable in one step: PATCH+1, MINOR+1 with patch reset,
+or MAJOR+1 with both reset. A skip (0.1.1 -> 0.1.5) or a downgrade is a typo, not
+an intent, and versions are immutable once published.
+
+Usage:
+ check_version_bump.py origin/main [--github-output]
+
+Emits `previous`, `version`, and `bump` as step outputs when asked. Exit 0 when
+the step is legal; 1 otherwise.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+
+from release_surface import ROOT, SLOTS, bump_kind, current_version, next_versions
+
+
+def surface_at(ref: str) -> dict[str, str]:
+ """Read every slot from a git ref rather than the working tree."""
+ blobs: dict[str, str] = {}
+ for slot in SLOTS:
+ if slot.path not in blobs:
+ blobs[slot.path] = subprocess.run(
+ ["git", "show", f"{ref}:{slot.path}"],
+ cwd=ROOT,
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout
+ return {slot.name: slot.read_text(blobs[slot.path]) for slot in SLOTS}
+
+
+def agreed(versions: dict[str, str], source: str) -> str:
+ distinct = sorted(set(versions.values()))
+ if len(distinct) != 1:
+ raise SystemExit(f"error: {source} disagrees on version: {distinct}")
+ return distinct[0]
+
+
+def main(argv: list[str]) -> int:
+ args = [arg for arg in argv[1:] if not arg.startswith("--")]
+ if len(args) != 1:
+ print(__doc__, file=sys.stderr)
+ return 2
+ ref = args[0]
+
+ previous = agreed(surface_at(ref), ref)
+ version = current_version()
+
+ try:
+ kind = bump_kind(previous, version)
+ except ValueError as error:
+ legal = next_versions(previous)
+ print(f"error: {error}", file=sys.stderr)
+ print(
+ "hint: every merge publishes, so bump the version — "
+ f"scripts/set_version.py --bump patch ({legal['patch']}), "
+ f"--bump minor ({legal['minor']}), or --bump major ({legal['major']})",
+ file=sys.stderr,
+ )
+ return 1
+
+ print(f"{previous} -> {version} ({kind})")
+
+ if "--github-output" in argv[1:]:
+ output = os.environ["GITHUB_OUTPUT"]
+ with open(output, "a", encoding="utf-8") as handle:
+ handle.write(f"previous={previous}\nversion={version}\nbump={kind}\n")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/scripts/registry_status.py b/scripts/registry_status.py
new file mode 100755
index 0000000..be82630
--- /dev/null
+++ b/scripts/registry_status.py
@@ -0,0 +1,108 @@
+#!/usr/bin/env python3
+"""Report which of the six artifacts already exist at a version.
+
+Registry versions are immutable, so both gates need this fact rather than a
+guess. The pull request gate uses it twice: the declared version must be absent
+everywhere (nothing to collide with), and the version on main must be present
+everywhere (no half-published release to stack on top of). The release workflow
+uses it to publish only what is missing — which makes a partially failed release
+repairable by rerunning it — and again at the end to prove the release landed
+before tagging it.
+
+Usage:
+ registry_status.py 0.1.2 --require-absent
+ registry_status.py 0.1.1 --require-present
+ registry_status.py 0.1.2 --github-output
+ registry_status.py 0.1.2 --require-present --retries 5 # allow for propagation
+
+Exit 0 when the requested condition holds; 1 otherwise.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+import time
+
+from release_surface import ARTIFACTS, is_published
+
+
+def survey(version: str) -> dict[str, bool]:
+ return {artifact.slug: is_published(artifact, version) for artifact in ARTIFACTS}
+
+
+def report(version: str, present: dict[str, bool]) -> None:
+ for artifact in ARTIFACTS:
+ state = "published" if present[artifact.slug] else "absent"
+ print(f"{state:<10} {artifact.describe()} {version}", flush=True)
+
+
+def main(argv: list[str]) -> int:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("version")
+ parser.add_argument(
+ "--require-absent",
+ action="store_true",
+ help="fail if any artifact already exists at this version",
+ )
+ parser.add_argument(
+ "--require-present",
+ action="store_true",
+ help="fail unless every artifact exists at this version",
+ )
+ parser.add_argument(
+ "--github-output",
+ action="store_true",
+ help="emit publish_ flags for the release workflow",
+ )
+ parser.add_argument(
+ "--retries",
+ type=int,
+ default=0,
+ help="re-survey this many times while --require-present is unmet (registry propagation)",
+ )
+ parser.add_argument("--delay", type=float, default=15.0, help="seconds between retries")
+ args = parser.parse_args(argv[1:])
+ version = args.version.lstrip("v")
+
+ present = survey(version)
+ for remaining in range(args.retries, 0, -1):
+ if not args.require_present or all(present.values()):
+ break
+ missing = [slug for slug, exists in present.items() if not exists]
+ print(f"waiting {args.delay:g}s for {', '.join(missing)} ({remaining} left)", flush=True)
+ time.sleep(args.delay)
+ present = survey(version)
+
+ report(version, present)
+
+ if args.github_output:
+ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as handle:
+ for slug, exists in present.items():
+ handle.write(f"publish_{slug}={'false' if exists else 'true'}\n")
+ handle.write(f"any_pending={'true' if not all(present.values()) else 'false'}\n")
+
+ if args.require_absent and any(present.values()):
+ taken = ", ".join(slug for slug, exists in present.items() if exists)
+ print(
+ f"error: version {version} is already published ({taken}); "
+ "registry versions are immutable, so pick the next version",
+ file=sys.stderr,
+ )
+ return 1
+
+ if args.require_present and not all(present.values()):
+ missing = ", ".join(slug for slug, exists in present.items() if not exists)
+ print(
+ f"error: release {version} is incomplete ({missing} never published); "
+ "rerun its release workflow — the publish jobs skip whatever already landed",
+ file=sys.stderr,
+ )
+ return 1
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/scripts/release_surface.py b/scripts/release_surface.py
new file mode 100755
index 0000000..4644142
--- /dev/null
+++ b/scripts/release_surface.py
@@ -0,0 +1,353 @@
+#!/usr/bin/env python3
+"""The release surface: every place the version is written, and every place it is published.
+
+One version drives six published artifacts across three registries, and it is
+written into sixteen authored locations in four file formats. This module is the
+single registry of both sets; the release scripts and workflows read it rather
+than re-deriving paths, so adding a port or a lockfile is a one-line change here.
+
+Writers are surgical (targeted line rewrites, not format round-trips) so that
+rewriting the current version is byte-identical and generated lockfiles are never
+reflowed.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable, Iterable
+
+ROOT = Path(__file__).resolve().parent.parent
+
+USER_AGENT = "tonalis-release-check (+https://github.com/drycode/tonalis)"
+
+# ---------------------------------------------------------------- semver
+
+_SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
+
+BUMPS = ("major", "minor", "patch")
+
+
+def parse_version(version: str) -> tuple[int, int, int]:
+ """Parse a plain MAJOR.MINOR.PATCH version.
+
+ Pre-release and build metadata are rejected: the six registries publish one
+ immutable version per release and nothing here knows how to order `1.0.0-rc1`.
+ """
+ match = _SEMVER.match(version)
+ if not match:
+ raise ValueError(f"not a plain MAJOR.MINOR.PATCH version: {version!r}")
+ major, minor, patch = (int(group) for group in match.groups())
+ return major, minor, patch
+
+
+def next_versions(previous: str) -> dict[str, str]:
+ """The three versions that may legally follow `previous`."""
+ major, minor, patch = parse_version(previous)
+ return {
+ "major": f"{major + 1}.0.0",
+ "minor": f"{major}.{minor + 1}.0",
+ "patch": f"{major}.{minor}.{patch + 1}",
+ }
+
+
+def bump_kind(previous: str, candidate: str) -> str:
+ """Classify `candidate` as a major/minor/patch step over `previous`.
+
+ Raises ValueError for anything that is not exactly one step: an unchanged
+ version, a downgrade, or a skip such as 0.1.1 -> 0.1.5.
+ """
+ legal = next_versions(previous)
+ parse_version(candidate)
+ for kind, version in legal.items():
+ if candidate == version:
+ return kind
+ raise ValueError(
+ f"{candidate!r} is not a single semver step from {previous!r}; "
+ f"expected one of {', '.join(legal[kind] for kind in BUMPS)}"
+ )
+
+
+# ---------------------------------------------------------------- slot readers/writers
+
+
+def _table_bounds(lines: list[str], table: str) -> tuple[int, int]:
+ """Half-open line range of the body of TOML table `[table]`."""
+ start: int | None = None
+ for index, line in enumerate(lines):
+ stripped = line.strip()
+ if stripped == f"[{table}]":
+ start = index + 1
+ continue
+ if start is not None and stripped.startswith("[") and stripped.endswith("]"):
+ return start, index
+ if start is None:
+ raise KeyError(f"no [{table}] table")
+ return start, len(lines)
+
+
+def _toml_key(table: str, key: str) -> tuple[Callable, Callable]:
+ pattern = re.compile(rf'^(\s*{re.escape(key)}\s*=\s*")([^"]+)(")')
+
+ def locate(text: str) -> tuple[list[str], int, re.Match]:
+ lines = text.splitlines(keepends=True)
+ low, high = _table_bounds([line.rstrip("\n") for line in lines], table)
+ for index in range(low, high):
+ match = pattern.match(lines[index])
+ if match:
+ return lines, index, match
+ raise KeyError(f"no {key!r} in [{table}]")
+
+ def read(text: str) -> str:
+ return locate(text)[2].group(2)
+
+ def write(text: str, version: str) -> str:
+ lines, index, match = locate(text)
+ lines[index] = f"{match.group(1)}{version}{match.group(3)}{lines[index][match.end(3):]}"
+ return "".join(lines)
+
+ return read, write
+
+
+def _regex_capture(pattern: str) -> tuple[Callable, Callable]:
+ compiled = re.compile(pattern)
+
+ def locate(text: str) -> re.Match:
+ match = compiled.search(text)
+ if not match:
+ raise KeyError(f"no match for {pattern!r}")
+ return match
+
+ def read(text: str) -> str:
+ return locate(text).group("version")
+
+ def write(text: str, version: str) -> str:
+ low, high = locate(text).span("version")
+ return text[:low] + version + text[high:]
+
+ return read, write
+
+
+def _json_key(*keys: str) -> tuple[Callable, Callable]:
+ def read(text: str) -> str:
+ node = json.loads(text)
+ for key in keys:
+ node = node[key]
+ return node
+
+ def write(text: str, version: str) -> str:
+ document = json.loads(text)
+ node = document
+ for key in keys[:-1]:
+ node = node[key]
+ node[keys[-1]] = version
+ return json.dumps(document, indent=2, ensure_ascii=False) + "\n"
+
+ return read, write
+
+
+def _lock_package(crate: str) -> tuple[Callable, Callable]:
+ name_pattern = re.compile(r'^name\s*=\s*"([^"]+)"')
+ version_pattern = re.compile(r'^(version\s*=\s*")([^"]+)(")')
+
+ def locate(text: str) -> tuple[list[str], int, re.Match]:
+ lines = text.splitlines(keepends=True)
+ current: str | None = None
+ for index, line in enumerate(lines):
+ named = name_pattern.match(line)
+ if named:
+ current = named.group(1)
+ continue
+ versioned = version_pattern.match(line)
+ if versioned and current == crate:
+ return lines, index, versioned
+ raise KeyError(f"no [[package]] {crate!r}")
+
+ def read(text: str) -> str:
+ return locate(text)[2].group(2)
+
+ def write(text: str, version: str) -> str:
+ lines, index, match = locate(text)
+ lines[index] = f"{match.group(1)}{version}{match.group(3)}{lines[index][match.end(3):]}"
+ return "".join(lines)
+
+ return read, write
+
+
+# ---------------------------------------------------------------- the surface
+
+
+@dataclass(frozen=True)
+class Slot:
+ """One authored location holding the version."""
+
+ path: str
+ label: str
+ group: str
+ _read: Callable[[str], str]
+ _write: Callable[[str, str], str]
+
+ @property
+ def name(self) -> str:
+ return f"{self.path}:{self.label}"
+
+ def read_text(self, text: str) -> str:
+ """Read this slot out of file contents supplied by the caller."""
+ return self._read(text)
+
+ def read(self, root: Path = ROOT) -> str:
+ return self.read_text((root / self.path).read_text())
+
+ def write(self, version: str, root: Path = ROOT) -> bool:
+ """Set this slot to `version`; True if the file changed."""
+ target = root / self.path
+ before = target.read_text()
+ after = self._write(before, version)
+ if after == before:
+ return False
+ target.write_text(after)
+ return True
+
+
+def _slot(path: str, label: str, group: str, io: tuple[Callable, Callable]) -> Slot:
+ return Slot(path, label, group, io[0], io[1])
+
+
+SLOTS: tuple[Slot, ...] = (
+ # ---- package manifests: what each registry publishes as its version
+ _slot("music-dsl/python/pyproject.toml", "project.version", "manifest", _toml_key("project", "version")),
+ _slot("music-dsl/rust/Cargo.toml", "package.version", "manifest", _toml_key("package", "version")),
+ _slot("music-dsl/ts/package.json", "version", "manifest", _json_key("version")),
+ _slot("tonalis/python/pyproject.toml", "project.version", "manifest", _toml_key("project", "version")),
+ _slot("tonalis/rust/Cargo.toml", "package.version", "manifest", _toml_key("package", "version")),
+ _slot("tonalis/ts/package.json", "version", "manifest", _json_key("version")),
+ # ---- internal pins: tonalis depends on the theory library at the same version
+ _slot(
+ "tonalis/python/pyproject.toml",
+ "dependencies.tonalis-music-dsl",
+ "pin",
+ _regex_capture(r"tonalis-music-dsl==(?P[0-9][0-9A-Za-z.\-+]*)"),
+ ),
+ _slot(
+ "tonalis/rust/Cargo.toml",
+ "dependencies.music_dsl.version",
+ "pin",
+ _regex_capture(r'(?m)^music_dsl\s*=\s*\{[^\n]*?version\s*=\s*"(?P[^"]+)"'),
+ ),
+ # ---- lockfiles: committed, so they drift silently unless checked
+ _slot("music-dsl/rust/Cargo.lock", "package.tonalis-music-dsl", "lock", _lock_package("tonalis-music-dsl")),
+ _slot("music-dsl/ts/package-lock.json", "version", "lock", _json_key("version")),
+ _slot("music-dsl/ts/package-lock.json", 'packages."".version', "lock", _json_key("packages", "", "version")),
+ _slot("tonalis/rust/Cargo.lock", "package.tonalis", "lock", _lock_package("tonalis")),
+ _slot("tonalis/rust/Cargo.lock", "package.tonalis-music-dsl", "lock", _lock_package("tonalis-music-dsl")),
+ _slot("tonalis/ts/package-lock.json", "version", "lock", _json_key("version")),
+ _slot("tonalis/ts/package-lock.json", 'packages."".version', "lock", _json_key("packages", "", "version")),
+ _slot(
+ "tonalis/ts/package-lock.json",
+ 'packages."../../music-dsl/ts".version',
+ "lock",
+ _json_key("packages", "../../music-dsl/ts", "version"),
+ ),
+)
+
+
+def read_surface(root: Path = ROOT) -> dict[str, str]:
+ """Every slot's current version, keyed by `path:label`."""
+ return {slot.name: slot.read(root) for slot in SLOTS}
+
+
+def current_version(root: Path = ROOT) -> str:
+ """The single version the surface agrees on; raises if it disagrees."""
+ versions = read_surface(root)
+ distinct = sorted(set(versions.values()))
+ if len(distinct) != 1:
+ raise ValueError(f"release surface disagrees on version: {distinct}")
+ return distinct[0]
+
+
+def write_surface(version: str, root: Path = ROOT) -> list[str]:
+ """Set every slot to `version`; returns the paths that changed."""
+ parse_version(version)
+ changed: list[str] = []
+ for slot in SLOTS:
+ if slot.write(version, root) and slot.path not in changed:
+ changed.append(slot.path)
+ return changed
+
+
+# ---------------------------------------------------------------- published artifacts
+
+
+@dataclass(frozen=True)
+class Artifact:
+ """One published package: a registry plus the name it is published under."""
+
+ registry: str
+ name: str
+ slug: str
+
+ def url(self, version: str) -> str:
+ quoted = urllib.parse.quote(self.name, safe="")
+ if self.registry == "pypi":
+ return f"https://pypi.org/pypi/{quoted}/{version}/json"
+ if self.registry == "npm":
+ return f"https://registry.npmjs.org/{quoted}/{version}"
+ if self.registry == "crates":
+ return f"https://crates.io/api/v1/crates/{quoted}/{version}"
+ raise ValueError(f"unknown registry {self.registry!r}")
+
+ def describe(self) -> str:
+ return f"{self.registry}:{self.name}"
+
+
+ARTIFACTS: tuple[Artifact, ...] = (
+ Artifact("pypi", "tonalis-music-dsl", "pypi_music_dsl"),
+ Artifact("pypi", "tonalis", "pypi_tonalis"),
+ Artifact("npm", "@tonalis/music-dsl", "npm_music_dsl"),
+ Artifact("npm", "tonalis", "npm_tonalis"),
+ Artifact("crates", "tonalis-music-dsl", "crates_music_dsl"),
+ Artifact("crates", "tonalis", "crates_tonalis"),
+)
+
+
+def http_status(url: str, timeout: float = 30.0) -> int:
+ """GET `url` and return its status, mapping HTTP errors to their code."""
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
+ try:
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ return response.status
+ except urllib.error.HTTPError as error:
+ return error.code
+
+
+def is_published(
+ artifact: Artifact,
+ version: str,
+ fetch: Callable[[str], int] = http_status,
+) -> bool:
+ """Whether `version` of `artifact` already exists in its registry.
+
+ Anything other than 200 or 404 raises: treating an outage or a rate limit as
+ "absent" would publish over a live release, and as "present" would silently
+ skip publishing it.
+ """
+ status = fetch(artifact.url(version))
+ if status == 200:
+ return True
+ if status == 404:
+ return False
+ raise RuntimeError(f"{artifact.describe()} {version}: unexpected HTTP {status}")
+
+
+def publication_state(
+ version: str,
+ artifacts: Iterable[Artifact] = ARTIFACTS,
+ fetch: Callable[[str], int] = http_status,
+) -> dict[str, bool]:
+ """Presence of `version` for each artifact, keyed by slug."""
+ return {artifact.slug: is_published(artifact, version, fetch) for artifact in artifacts}
diff --git a/scripts/set_version.py b/scripts/set_version.py
new file mode 100755
index 0000000..50211b2
--- /dev/null
+++ b/scripts/set_version.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python3
+"""Set the release version across every authored location.
+
+Every merge to main publishes, so every pull request declares its version. The
+version lives in sixteen places across four file formats (see
+`scripts/release_surface.py`); editing them by hand is how lockfiles and internal
+pins drift out of step. Run this instead.
+
+Usage:
+ set_version.py 0.2.0 # set an explicit version
+ set_version.py --bump minor # step the current version
+
+Then commit the result. `version-gate.yml` verifies it on the pull request and
+`release.yml` publishes it on merge.
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+
+from release_surface import BUMPS, current_version, next_versions, write_surface
+
+
+def main(argv: list[str]) -> int:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ target = parser.add_mutually_exclusive_group(required=True)
+ target.add_argument("version", nargs="?", help="explicit MAJOR.MINOR.PATCH version")
+ target.add_argument("--bump", choices=BUMPS, help="step the current version")
+ args = parser.parse_args(argv[1:])
+
+ previous = current_version()
+ version = args.version or next_versions(previous)[args.bump]
+
+ if version == previous:
+ print(f"error: already at {version}; every merge must ship a new version", file=sys.stderr)
+ return 1
+
+ changed = write_surface(version)
+ print(f"{previous} -> {version}")
+ for path in changed:
+ print(f" updated {path}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/scripts/tests/conftest.py b/scripts/tests/conftest.py
new file mode 100644
index 0000000..5120590
--- /dev/null
+++ b/scripts/tests/conftest.py
@@ -0,0 +1,6 @@
+"""Make the release scripts importable the way the workflows invoke them."""
+
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
diff --git a/scripts/tests/test_release_scripts.py b/scripts/tests/test_release_scripts.py
new file mode 100644
index 0000000..674adee
--- /dev/null
+++ b/scripts/tests/test_release_scripts.py
@@ -0,0 +1,176 @@
+"""Tests for the release surface — the logic no other suite guards.
+
+Six artifacts publish from one version written into sixteen authored locations,
+and registry versions are immutable. The two failure modes worth defending are a
+version that only *looks* synced (a lockfile left behind) and a presence check
+that guesses when a registry misbehaves.
+"""
+
+from __future__ import annotations
+
+import json
+import shutil
+
+import pytest
+
+import release_surface as surface
+from release_surface import ARTIFACTS, SLOTS, Artifact, bump_kind, is_published
+
+
+# ---------------------------------------------------------------- semver steps
+
+
+@pytest.mark.parametrize(
+ "previous,candidate,expected",
+ [
+ ("0.1.1", "0.1.2", "patch"),
+ ("0.1.1", "0.2.0", "minor"),
+ ("0.1.1", "1.0.0", "major"),
+ ("1.9.9", "1.9.10", "patch"),
+ ("1.9.9", "1.10.0", "minor"),
+ ("1.9.9", "2.0.0", "major"),
+ ],
+)
+def test_legal_steps_are_classified(previous, candidate, expected):
+ assert bump_kind(previous, candidate) == expected
+
+
+@pytest.mark.parametrize(
+ "previous,candidate",
+ [
+ ("0.1.1", "0.1.1"), # unchanged: every merge must ship a new version
+ ("0.1.1", "0.1.0"), # downgrade
+ ("0.1.1", "0.0.9"), # downgrade across minor
+ ("0.1.1", "0.1.5"), # skipped patches
+ ("0.1.1", "0.3.0"), # skipped minor
+ ("0.1.1", "2.0.0"), # skipped major
+ ("0.1.1", "1.1.0"), # major bump without resetting minor
+ ("0.1.1", "0.2.1"), # minor bump without resetting patch
+ ("0.1.1", "0.1.2-rc1"), # pre-release: registries publish one version
+ ("0.1.1", "0.1.2+build"),
+ ("0.1.1", "01.1.2"), # leading zero
+ ("0.1.1", "v0.1.2"), # tag spelling, not a version
+ ],
+)
+def test_illegal_steps_are_rejected(previous, candidate):
+ with pytest.raises(ValueError):
+ bump_kind(previous, candidate)
+
+
+# ---------------------------------------------------------------- the surface
+
+
+def test_surface_covers_every_published_manifest():
+ manifests = {slot.path for slot in SLOTS if slot.group == "manifest"}
+ assert manifests == {
+ "music-dsl/python/pyproject.toml",
+ "music-dsl/rust/Cargo.toml",
+ "music-dsl/ts/package.json",
+ "tonalis/python/pyproject.toml",
+ "tonalis/rust/Cargo.toml",
+ "tonalis/ts/package.json",
+ }
+ assert len({slot.name for slot in SLOTS}) == len(SLOTS), "duplicate slot"
+
+
+def test_repository_surface_agrees():
+ """The checked-in tree is always releasable: one version, everywhere."""
+ assert len(set(surface.read_surface().values())) == 1
+
+
+@pytest.fixture
+def tree(tmp_path):
+ """A copy of just the files the surface touches, for write tests."""
+ for path in {slot.path for slot in SLOTS}:
+ target = tmp_path / path
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(surface.ROOT / path, target)
+ return tmp_path
+
+
+def test_rewriting_the_current_version_is_byte_identical(tree):
+ """Writers must edit in place, not reflow generated lockfiles."""
+ before = {path: (tree / path).read_bytes() for path in {slot.path for slot in SLOTS}}
+ surface.write_surface(surface.current_version(tree), tree)
+ for path, content in before.items():
+ assert (tree / path).read_bytes() == content, f"{path} was reformatted"
+
+
+def test_write_surface_moves_every_slot(tree):
+ changed = surface.write_surface("9.8.7", tree)
+ assert set(changed) == {slot.path for slot in SLOTS}
+ assert surface.read_surface(tree) == {slot.name: "9.8.7" for slot in SLOTS}
+ assert surface.current_version(tree) == "9.8.7"
+
+
+def test_write_surface_updates_the_internal_pins(tree):
+ surface.write_surface("2.3.4", tree)
+ python = (tree / "tonalis/python/pyproject.toml").read_text()
+ assert 'dependencies = ["tonalis-music-dsl==2.3.4"]' in python
+ rust = (tree / "tonalis/rust/Cargo.toml").read_text()
+ assert 'package = "tonalis-music-dsl", path = "../../music-dsl/rust", version = "2.3.4"' in rust
+
+
+def test_write_surface_updates_the_linked_npm_dependency(tree):
+ surface.write_surface("2.3.4", tree)
+ lock = json.loads((tree / "tonalis/ts/package-lock.json").read_text())
+ assert lock["packages"]["../../music-dsl/ts"]["version"] == "2.3.4"
+
+
+def test_a_stale_lockfile_is_caught(tree):
+ surface.write_surface("3.0.0", tree)
+ stale = next(slot for slot in SLOTS if slot.path == "music-dsl/rust/Cargo.lock")
+ stale.write("2.9.9", tree)
+ with pytest.raises(ValueError, match="disagrees"):
+ surface.current_version(tree)
+
+
+def test_write_surface_rejects_a_non_semver_version(tree):
+ with pytest.raises(ValueError):
+ surface.write_surface("1.2", tree)
+
+
+# ---------------------------------------------------------------- registries
+
+
+def test_every_artifact_has_a_distinct_slug():
+ assert len({artifact.slug for artifact in ARTIFACTS}) == len(ARTIFACTS) == 6
+
+
+@pytest.mark.parametrize(
+ "artifact,expected",
+ [
+ (
+ Artifact("pypi", "tonalis-music-dsl", "pypi_music_dsl"),
+ "https://pypi.org/pypi/tonalis-music-dsl/0.1.2/json",
+ ),
+ (
+ Artifact("npm", "@tonalis/music-dsl", "npm_music_dsl"),
+ "https://registry.npmjs.org/%40tonalis%2Fmusic-dsl/0.1.2",
+ ),
+ (
+ Artifact("crates", "tonalis", "crates_tonalis"),
+ "https://crates.io/api/v1/crates/tonalis/0.1.2",
+ ),
+ ],
+)
+def test_registry_urls(artifact, expected):
+ assert artifact.url("0.1.2") == expected
+
+
+def test_presence_maps_200_and_404():
+ artifact = ARTIFACTS[0]
+ assert is_published(artifact, "0.1.2", fetch=lambda url: 200) is True
+ assert is_published(artifact, "0.1.2", fetch=lambda url: 404) is False
+
+
+@pytest.mark.parametrize("status", [403, 429, 500, 502])
+def test_presence_refuses_to_guess(status):
+ """An outage must stop the release, not be read as absent or present."""
+ with pytest.raises(RuntimeError, match=f"HTTP {status}"):
+ is_published(ARTIFACTS[0], "0.1.2", fetch=lambda url: status)
+
+
+def test_publication_state_is_keyed_by_slug():
+ state = surface.publication_state("0.1.2", fetch=lambda url: 404)
+ assert state == {artifact.slug: False for artifact in ARTIFACTS}
diff --git a/tonalis/python/pyproject.toml b/tonalis/python/pyproject.toml
index 42a29e8..0ec0870 100644
--- a/tonalis/python/pyproject.toml
+++ b/tonalis/python/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tonalis"
-version = "0.1.1"
+version = "0.1.2"
description = "Tonalis — a generic, format-agnostic music-harmony DSL (parse/lint/AST/JSON/text)."
requires-python = ">=3.11"
readme = "README.md"
@@ -12,7 +12,7 @@ license = { file = "LICENSE" }
classifiers = [
"License :: OSI Approved :: MIT License",
]
-dependencies = ["tonalis-music-dsl==0.1.1"]
+dependencies = ["tonalis-music-dsl==0.1.2"]
[project.urls]
Homepage = "https://github.com/drycode/tonalis"
diff --git a/tonalis/rust/Cargo.lock b/tonalis/rust/Cargo.lock
index 55ee6ef..6c45496 100644
--- a/tonalis/rust/Cargo.lock
+++ b/tonalis/rust/Cargo.lock
@@ -149,7 +149,7 @@ dependencies = [
[[package]]
name = "tonalis"
-version = "0.1.1"
+version = "0.1.2"
dependencies = [
"regex",
"serde",
@@ -159,7 +159,7 @@ dependencies = [
[[package]]
name = "tonalis-music-dsl"
-version = "0.1.1"
+version = "0.1.2"
dependencies = [
"regex",
"serde",
diff --git a/tonalis/rust/Cargo.toml b/tonalis/rust/Cargo.toml
index 28545be..ac1bd8a 100644
--- a/tonalis/rust/Cargo.toml
+++ b/tonalis/rust/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "tonalis"
-version = "0.1.1"
+version = "0.1.2"
edition = "2021"
description = "Tonalis — a generic, format-agnostic music-harmony DSL (parse/lint/AST/JSON/text)."
readme = "README.md"
@@ -12,7 +12,7 @@ repository = "https://github.com/drycode/tonalis"
crate-type = ["rlib"]
[dependencies]
-music_dsl = { package = "tonalis-music-dsl", path = "../../music-dsl/rust", version = "0.1.1" }
+music_dsl = { package = "tonalis-music-dsl", path = "../../music-dsl/rust", version = "0.1.2" }
regex = "1"
serde = { version = "1", features = ["derive"] }
# preserve_order keeps serialized AST object keys in insertion order (matching the Python/TS
diff --git a/tonalis/ts/package-lock.json b/tonalis/ts/package-lock.json
index 70236d6..4b77bca 100644
--- a/tonalis/ts/package-lock.json
+++ b/tonalis/ts/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "tonalis",
- "version": "0.1.1",
+ "version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tonalis",
- "version": "0.1.1",
+ "version": "0.1.2",
"license": "MIT",
"dependencies": {
"@tonalis/music-dsl": "file:../../music-dsl/ts"
@@ -19,7 +19,7 @@
},
"../../music-dsl/ts": {
"name": "@tonalis/music-dsl",
- "version": "0.1.1",
+ "version": "0.1.2",
"license": "MIT",
"devDependencies": {
"@types/node": "^22.10.2",
diff --git a/tonalis/ts/package.json b/tonalis/ts/package.json
index 07f3199..9a5a295 100644
--- a/tonalis/ts/package.json
+++ b/tonalis/ts/package.json
@@ -1,6 +1,6 @@
{
"name": "tonalis",
- "version": "0.1.1",
+ "version": "0.1.2",
"description": "Tonalis — a generic, format-agnostic music-harmony DSL (parse/lint/AST/JSON/text). Depends only on the pure @tonalis/music-dsl theory library; ships compiled ESM + type declarations, browser + Node safe.",
"type": "module",
"main": "./dist/index.js",