Skip to content

Commit 40b8b90

Browse files
authored
[release] Publish a semantic release on every merge (#3)
The committer declares the version; CI verifies it and publishes. release.yml now triggers on pushes to main rather than on a tag, so a merged pull request ships a release instead of queueing one behind a manual tag. scripts/release_surface.py is the single registry of the sixteen authored version locations and the six published artifacts. set_version.py rewrites all sixteen, so lockfiles and internal pins cannot drift; the writers edit in place, which the tests pin as byte-identical. version-gate.yml refuses a pull request unless the version agrees everywhere, is exactly one semver step over the base branch, is unclaimed by every registry, and follows a fully published predecessor. The last check yields to a release:override label so it cannot deadlock a repair. guard now asks each registry what already exists and publishes only what is missing, so rerunning a partially failed release completes it rather than failing on duplicate versions — the 0.1.1 recovery that had to be done by hand. record re-interrogates the registries before tagging, because a skipped dependent job is otherwise indistinguishable from a successful one. The npm dependency range is derived from the manifest, replacing a hardcoded ^0.1.1 that would have shipped a stale pin. Solves: release on merge with a verified semantic version Tests: pytest scripts/tests (36); actionlint; npm ci + suites (852); cargo test
1 parent 8dba6dd commit 40b8b90

23 files changed

Lines changed: 1269 additions & 97 deletions

‎.github/workflows/ci.yml‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
name: ci
22

33
on:
4-
push:
5-
branches: [main]
64
pull_request:
75
# Callable so the release workflow gates publishing on this exact suite
8-
# rather than duplicating (or skipping) it.
6+
# rather than duplicating (or skipping) it. Pushes to main are covered that
7+
# way — release.yml runs this suite on every merge — so there is no `push`
8+
# trigger here, which would run the whole suite twice per merge.
99
workflow_call:
1010

1111
jobs:
@@ -38,6 +38,9 @@ jobs:
3838
- name: MusicDSL conformance (Python reference)
3939
run: pytest conformance/music-dsl/runners/python -q
4040

41+
- name: Release surface tests
42+
run: pytest scripts/tests -q
43+
4144
# Informational coverage signal (not a gate). Uses the pytest binary, not
4245
# `python -m pytest` — the latter puts the repo root on sys.path[0], where
4346
# the tonalis/ source dir shadows the installed package.

‎.github/workflows/release.yml‎

Lines changed: 145 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,61 +2,111 @@ name: release
22

33
# Publishes the six artifacts — the theory lib (PyPI/crates: tonalis-music-dsl, npm: @tonalis/music-dsl) + tonalis, each to PyPI / npm / crates.io —
44
# via OIDC trusted publishing. No stored tokens: each job mints a short-lived,
5-
# workflow-scoped credential from the registry.
5+
# workflow-scoped credential from the registry. This is the ONLY workflow that
6+
# publishes; nothing is ever published from a laptop.
67
#
7-
# Prerequisites (one-time, human, in each registry UI) BEFORE the first tag:
8-
# - PyPI: configure a Trusted Publisher for tonalis-music-dsl and for tonalis
9-
# (owner=drycode, repo=tonalis, workflow=release.yml).
10-
# - npm: configure a Trusted Publisher for each package (same coordinates).
11-
# - crates.io: configure a Trusted Publisher for each crate (same coordinates).
12-
# If a publisher is not configured, that package's job fails — the others are unaffected.
8+
# Trigger: every push to main, which means every merged pull request ships a release.
9+
# The version comes from the repository, not from a tag: version-gate.yml has already
10+
# proven on the pull request that it is a legal single step and that no registry has
11+
# claimed it. The tag is created at the end as a record, not consumed as the trigger.
1312
#
14-
# Gating: nothing publishes until two jobs pass — `ci` (the full test suite, reused from
15-
# ci.yml via workflow_call) and `guard` (the pushed tag matches all six manifest versions).
16-
# The three base-library publish jobs `needs: [ci, guard]`; the dependent jobs inherit the
17-
# gate transitively through their library dependency. PyPI/npm/crates versions are immutable,
18-
# so a red suite or a mismatched tag must fail before any artifact is built.
13+
# Prerequisites (one-time, human, in each registry UI):
14+
# - PyPI: a Trusted Publisher for tonalis-music-dsl and for tonalis
15+
# (owner=drycode, repo=tonalis, workflow=release.yml, no environment).
16+
# - npm: a Trusted Publisher for each package (same coordinates).
17+
# - crates.io: a Trusted Publisher for each crate (same coordinates).
18+
# If a publisher is not configured, that package's job fails — the others are
19+
# unaffected, and rerunning the run publishes only what is still missing.
1920
#
20-
# Ordering: tonalis depends on the music-dsl library in all three ecosystems, so the base library
21-
# publishes first and the dependent job `needs:` it. (crates.io: modern `cargo publish`
22-
# blocks until the new version is in the index, so the dependent resolve is race-free.)
21+
# Gating: nothing publishes until `ci` (the full suite, reused from ci.yml via
22+
# workflow_call) and `guard` (the sixteen authored version locations agree) both pass.
23+
#
24+
# Idempotence: `guard` asks each registry whether this version already exists and
25+
# emits one flag per artifact. An artifact that is already published is skipped, so
26+
# rerunning a partially failed release completes it instead of failing on duplicate
27+
# versions, and a push to main that carries no version bump is a clean no-op.
28+
#
29+
# Ordering: tonalis depends on the music-dsl library in all three ecosystems, so the
30+
# base library publishes first and the dependent job `needs:` it. (crates.io: modern
31+
# `cargo publish` blocks until the new version is in the index, so the dependent
32+
# resolve is race-free.) A dependent job tolerates a *skipped* base library — that
33+
# means the base was already published — but never a failed one, and it re-asserts
34+
# the `ci` and `guard` gates that `!cancelled()` would otherwise let it bypass.
2335
#
2436
# Note: npm --provenance is intentionally NOT used here. Provenance requires a public
25-
# source repo, but this workflow first runs while drycode/tonalis is still private
26-
# (publish → verify → then flip public). Enable --provenance in a later release once the
27-
# repo is public.
37+
# source repo, but this workflow first ran while drycode/tonalis was still private
38+
# (publish → verify → then flip public). Enable --provenance in a later release.
2839

2940
on:
3041
push:
31-
tags:
32-
- 'v*'
42+
branches: [main]
3343

3444
permissions:
3545
contents: read
3646

47+
# One release at a time. Cancellation is disabled: a queued release must run, not
48+
# be discarded, or its version would never be published.
49+
concurrency:
50+
group: tonalis-release
51+
cancel-in-progress: false
52+
3753
jobs:
3854
# ---------- gates ----------
3955
# Full test suite (Python/TS/Rust units + conformance + 3-way differential fuzzer),
40-
# reused verbatim from ci.yml. A tag can point at any commit, so re-run it here rather
41-
# than trusting that CI happened to pass on this SHA.
56+
# reused verbatim from ci.yml. This is the only run of it for a merge commit: ci.yml
57+
# itself no longer triggers on pushes to main.
4258
ci:
4359
uses: ./.github/workflows/ci.yml
4460

45-
# The tag must equal the version in all six manifests, and they must agree with each
46-
# other. Logic + local tests: scripts/check_release_version.py.
61+
# The sixteen authored version locations must agree with each other, and each
62+
# registry is asked whether it already has this version.
63+
# Logic + tests: scripts/release_surface.py, scripts/tests/test_release_scripts.py.
4764
guard:
4865
runs-on: ubuntu-latest
66+
outputs:
67+
version: ${{ steps.version.outputs.version }}
68+
any_pending: ${{ steps.status.outputs.any_pending }}
69+
publish_pypi_music_dsl: ${{ steps.status.outputs.publish_pypi_music_dsl }}
70+
publish_pypi_tonalis: ${{ steps.status.outputs.publish_pypi_tonalis }}
71+
publish_npm_music_dsl: ${{ steps.status.outputs.publish_npm_music_dsl }}
72+
publish_npm_tonalis: ${{ steps.status.outputs.publish_npm_tonalis }}
73+
publish_crates_music_dsl: ${{ steps.status.outputs.publish_crates_music_dsl }}
74+
publish_crates_tonalis: ${{ steps.status.outputs.publish_crates_tonalis }}
4975
steps:
5076
- uses: actions/checkout@v7
5177
- uses: actions/setup-python@v7
5278
with:
5379
python-version: '3.12'
54-
- name: Tag matches all six manifest versions
55-
run: python scripts/check_release_version.py "$GITHUB_REF_NAME"
80+
81+
- name: Every authored location agrees on the version
82+
run: python scripts/check_release_version.py
83+
84+
- name: Resolve the version
85+
id: version
86+
run: echo "version=$(python scripts/check_release_version.py --print)" >> "$GITHUB_OUTPUT"
87+
88+
- name: Ask each registry what is already published
89+
id: status
90+
run: |
91+
python scripts/registry_status.py \
92+
"${{ steps.version.outputs.version }}" --github-output
93+
94+
- name: Summary
95+
run: |
96+
{
97+
echo "### Release ${{ steps.version.outputs.version }}"
98+
echo
99+
if [ "${{ steps.status.outputs.any_pending }}" = "true" ]; then
100+
echo "Publishing the artifacts still missing from their registries."
101+
else
102+
echo "Already published everywhere — nothing to do."
103+
fi
104+
} >> "$GITHUB_STEP_SUMMARY"
56105
57106
# ---------- PyPI ----------
58107
pypi-music-dsl:
59108
needs: [ci, guard]
109+
if: ${{ needs.guard.outputs.publish_pypi_music_dsl == 'true' }}
60110
runs-on: ubuntu-latest
61111
permissions:
62112
id-token: write # OIDC
@@ -73,7 +123,13 @@ jobs:
73123
packages-dir: music-dsl/python/dist
74124

75125
pypi-tonalis:
76-
needs: pypi-music-dsl
126+
needs: [ci, guard, pypi-music-dsl]
127+
if: >-
128+
${{ !cancelled()
129+
&& needs.ci.result == 'success'
130+
&& needs.guard.result == 'success'
131+
&& needs.guard.outputs.publish_pypi_tonalis == 'true'
132+
&& needs['pypi-music-dsl'].result != 'failure' }}
77133
runs-on: ubuntu-latest
78134
permissions:
79135
id-token: write
@@ -92,6 +148,7 @@ jobs:
92148
# ---------- npm ----------
93149
npm-music-dsl:
94150
needs: [ci, guard]
151+
if: ${{ needs.guard.outputs.publish_npm_music_dsl == 'true' }}
95152
runs-on: ubuntu-latest
96153
permissions:
97154
id-token: write
@@ -108,7 +165,13 @@ jobs:
108165
working-directory: music-dsl/ts
109166

110167
npm-tonalis:
111-
needs: npm-music-dsl
168+
needs: [ci, guard, npm-music-dsl]
169+
if: >-
170+
${{ !cancelled()
171+
&& needs.ci.result == 'success'
172+
&& needs.guard.result == 'success'
173+
&& needs.guard.outputs.publish_npm_tonalis == 'true'
174+
&& needs['npm-music-dsl'].result != 'failure' }}
112175
runs-on: ubuntu-latest
113176
permissions:
114177
id-token: write
@@ -125,16 +188,17 @@ jobs:
125188
working-directory: music-dsl/ts
126189
- run: npm ci
127190
working-directory: tonalis/ts
128-
# Approach A: the repo keeps a local `file:` link to @tonalis/music-dsl for monorepo dev;
129-
# rewrite it to the published version range only in the publish artifact.
130-
- run: npm pkg set 'dependencies[@tonalis/music-dsl]=^0.1.1'
191+
# The repo keeps a local `file:` link to @tonalis/music-dsl for monorepo dev;
192+
# rewrite it to this release's published range, in the publish artifact only.
193+
- run: npm pkg set "dependencies[@tonalis/music-dsl]=^${{ needs.guard.outputs.version }}"
131194
working-directory: tonalis/ts
132195
- run: npm publish --access public
133196
working-directory: tonalis/ts
134197

135198
# ---------- crates.io ----------
136199
crates-music-dsl:
137200
needs: [ci, guard]
201+
if: ${{ needs.guard.outputs.publish_crates_music_dsl == 'true' }}
138202
runs-on: ubuntu-latest
139203
permissions:
140204
id-token: write
@@ -149,7 +213,13 @@ jobs:
149213
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
150214

151215
crates-tonalis:
152-
needs: crates-music-dsl
216+
needs: [ci, guard, crates-music-dsl]
217+
if: >-
218+
${{ !cancelled()
219+
&& needs.ci.result == 'success'
220+
&& needs.guard.result == 'success'
221+
&& needs.guard.outputs.publish_crates_tonalis == 'true'
222+
&& needs['crates-music-dsl'].result != 'failure' }}
153223
runs-on: ubuntu-latest
154224
permissions:
155225
id-token: write
@@ -162,3 +232,46 @@ jobs:
162232
working-directory: tonalis/rust
163233
env:
164234
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
235+
236+
# ---------- record ----------
237+
# Proves the release actually landed in all six registries, then tags it. Asking
238+
# the registries is stronger than reading job results: a skipped dependent job is
239+
# indistinguishable from a successful one without this. If anything is missing this
240+
# job fails and no tag is written — rerun the run to publish the remainder.
241+
#
242+
# A GITHUB_TOKEN-created tag does not trigger workflows, and this workflow no longer
243+
# listens for tags, so there is no recursion.
244+
record:
245+
needs: [ci, guard, pypi-tonalis, npm-tonalis, crates-tonalis]
246+
if: >-
247+
${{ !cancelled()
248+
&& needs.ci.result == 'success'
249+
&& needs.guard.result == 'success'
250+
&& needs.guard.outputs.any_pending == 'true' }}
251+
runs-on: ubuntu-latest
252+
permissions:
253+
contents: write
254+
steps:
255+
- uses: actions/checkout@v7
256+
- uses: actions/setup-python@v7
257+
with:
258+
python-version: '3.12'
259+
260+
- name: All six artifacts are published
261+
run: |
262+
python scripts/registry_status.py \
263+
"${{ needs.guard.outputs.version }}" --require-present --retries 6 --delay 20
264+
265+
- name: Tag the release
266+
env:
267+
GH_TOKEN: ${{ github.token }}
268+
VERSION: ${{ needs.guard.outputs.version }}
269+
run: |
270+
if gh release view "v$VERSION" >/dev/null 2>&1; then
271+
echo "v$VERSION is already recorded"
272+
exit 0
273+
fi
274+
gh release create "v$VERSION" \
275+
--target "$GITHUB_SHA" \
276+
--title "v$VERSION" \
277+
--generate-notes

‎.github/workflows/version-gate.yml‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: version-gate
2+
3+
# Every merge to main publishes a release (see release.yml), so every pull request
4+
# must declare the version it ships. This is the referee: the committer owns the
5+
# number, and nothing merges unless it is a legal, unclaimed, single step forward.
6+
#
7+
# Bump with `python scripts/set_version.py --bump patch|minor|major`, which rewrites
8+
# all sixteen authored locations at once. See RELEASING.md.
9+
#
10+
# Break glass: label the pull request `release:override` to skip the "previous
11+
# release is complete" check. That check exists to stop a version gap, but it would
12+
# otherwise deadlock a pull request that fixes a broken release.
13+
14+
on:
15+
pull_request:
16+
branches: [main]
17+
18+
permissions:
19+
contents: read
20+
21+
concurrency:
22+
group: version-gate-${{ github.event.pull_request.number }}
23+
cancel-in-progress: true
24+
25+
jobs:
26+
version:
27+
runs-on: ubuntu-latest
28+
steps:
29+
- uses: actions/checkout@v7
30+
31+
- uses: actions/setup-python@v7
32+
with:
33+
python-version: '3.12'
34+
35+
# The bump is measured against the base branch, which the default shallow
36+
# pull request checkout does not fetch.
37+
- name: Fetch the base branch
38+
run: |
39+
git fetch --depth=1 origin \
40+
"+refs/heads/${GITHUB_BASE_REF}:refs/remotes/origin/${GITHUB_BASE_REF}"
41+
42+
- name: Every authored location agrees on the version
43+
run: python scripts/check_release_version.py
44+
45+
- name: The version steps exactly once over the base branch
46+
id: bump
47+
run: python scripts/check_version_bump.py "origin/${GITHUB_BASE_REF}" --github-output
48+
49+
- name: The previous release is complete
50+
if: ${{ !contains(github.event.pull_request.labels.*.name, 'release:override') }}
51+
run: python scripts/registry_status.py "${{ steps.bump.outputs.previous }}" --require-present
52+
53+
- name: The declared version is unclaimed
54+
run: python scripts/registry_status.py "${{ steps.bump.outputs.version }}" --require-absent
55+
56+
- name: Summary
57+
run: |
58+
{
59+
echo "### Release on merge"
60+
echo
61+
echo "\`${{ steps.bump.outputs.previous }}\` → \`${{ steps.bump.outputs.version }}\`" \
62+
"(**${{ steps.bump.outputs.bump }}**)"
63+
echo
64+
echo "Merging this pull request publishes all six artifacts at" \
65+
"\`${{ steps.bump.outputs.version }}\`."
66+
} >> "$GITHUB_STEP_SUMMARY"

0 commit comments

Comments
 (0)