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",