@@ -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
2940on :
3041 push :
31- tags :
32- - ' v*'
42+ branches : [main]
3343
3444permissions :
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+
3753jobs :
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
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
0 commit comments