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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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.
Expand Down
177 changes: 145 additions & 32 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -125,16 +188,17 @@ 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

# ---------- 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
Expand All @@ -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
Expand All @@ -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
66 changes: 66 additions & 0 deletions .github/workflows/version-gate.yml
Original file line number Diff line number Diff line change
@@ -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"
Loading