From 57a62faa04966140ec62d004819e369c7500c81b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hl=C3=B6=C3=B0ver=20Sigur=C3=B0sson?= Date: Thu, 29 Oct 2026 14:19:07 +0200 Subject: [PATCH] fix(ci): release plugins from checked-in versions and labelled merges --- .github/actions/check-pr-label/action.yml | 50 ------ .github/scripts/release.py | 98 +++++++++++ .github/scripts/test_release.py | 204 ++++++++++++++++++++++ .github/workflows/plugins_build.yml | 83 +++++---- README.md | 28 +++ package-lock.json | 4 +- package.json | 2 +- wasm/README.md | 10 +- 8 files changed, 386 insertions(+), 93 deletions(-) delete mode 100644 .github/actions/check-pr-label/action.yml create mode 100644 .github/scripts/release.py create mode 100644 .github/scripts/test_release.py diff --git a/.github/actions/check-pr-label/action.yml b/.github/actions/check-pr-label/action.yml deleted file mode 100644 index 6cd7be4..0000000 --- a/.github/actions/check-pr-label/action.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: 'Check PR Label on Merge Commit' -description: 'Checks if the merged PR associated with the current commit has a specific label.' -inputs: - label: - description: 'Label to check for (e.g., release)' - required: true - github_token: - description: 'GitHub token' - required: true -outputs: - has_label: - description: 'true if label exists, false otherwise' - value: ${{ steps.check_label.outputs.has_label }} -runs: - using: "composite" - steps: - - name: Find PR and check label - id: check_label - shell: bash - env: - GITHUB_TOKEN: ${{ inputs.github_token }} - LABEL_TO_CHECK: ${{ inputs.label }} - run: | - command -v gh >/dev/null 2>&1 || { - curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ - | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null - sudo apt-get update - sudo apt-get install -y gh - } - - PR_NUMBER=$(gh pr list --state merged --limit 100 --json number,mergeCommit \ - -q ".[] | select(.mergeCommit.oid==\"${GITHUB_SHA}\") | .number") - - if [ -z "$PR_NUMBER" ]; then - echo "No merged PR found for commit $GITHUB_SHA" - echo "has_label=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - LABELS=$(gh pr view "$PR_NUMBER" --json labels -q '.labels[].name') - - if echo "$LABELS" | grep -qw "$LABEL_TO_CHECK"; then - echo "Label $LABEL_TO_CHECK found for PR #$PR_NUMBER" - echo "has_label=true" >> "$GITHUB_OUTPUT" - else - echo "Label $LABEL_TO_CHECK not found for PR #$PR_NUMBER" - echo "has_label=false" >> "$GITHUB_OUTPUT" - fi diff --git a/.github/scripts/release.py b/.github/scripts/release.py new file mode 100644 index 0000000..65b19ed --- /dev/null +++ b/.github/scripts/release.py @@ -0,0 +1,98 @@ +"""Read the release version and select labelled merges for native releases.""" + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess + + +VERSION = re.compile( + r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" + r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" +) +RELEASE_BRANCHES = {"refs/heads/main", "refs/heads/develop"} + + +def read_version(root): + package = json.loads((root / "package.json").read_text()) + lock = json.loads((root / "package-lock.json").read_text()) + version = package["version"] + match = VERSION.fullmatch(version) if isinstance(version, str) else None + if not match or any( + part.isdigit() and len(part) > 1 and part.startswith("0") + for part in (match[4] or "").split(".") + ): + raise ValueError("package.json must contain a SemVer version without build metadata") + if lock["version"] != version or lock["packages"][""]["version"] != version: + raise ValueError("package.json and package-lock.json versions must match") + return version + + +def release_metadata(version, event, ref, sha, repository, pull_requests): + release = event == "push" and ref in RELEASE_BRANCHES and any( + pr["merged_at"] + and pr["merge_commit_sha"] == sha + and pr["base"]["ref"] == ref.removeprefix("refs/heads/") + and pr["base"]["repo"]["full_name"] == repository + and any(label["name"] == "release" for label in pr["labels"]) + for pr in pull_requests + ) + prerelease = "-" in version + if release and ref == "refs/heads/main" and prerelease: + raise ValueError("A release from main needs a stable version in package.json") + if release and ref == "refs/heads/develop" and not prerelease: + raise ValueError("A release from develop needs a prerelease version in package.json") + return { + "version": version, + "release": str(bool(release)).lower(), + "prerelease": str(prerelease).lower(), + } + + +def check_tag(version, sha): + ref = f"refs/tags/{version}" + result = subprocess.run(["git", "show-ref", "--verify", "--quiet", ref]) + if result.returncode == 1: + return + result.check_returncode() + target = subprocess.check_output( + ["git", "rev-parse", "--verify", f"{ref}^{{commit}}"], text=True + ).strip() + if target != sha: + raise ValueError( + f"Tag {version} already points to {target}, not {sha}; choose a new version" + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check-tag", action="store_true") + args = parser.parse_args() + root = Path(__file__).resolve().parents[2] + version = read_version(root) + if args.check_tag: + check_tag(version, os.environ["GITHUB_SHA"]) + return + + event = os.environ["GITHUB_EVENT_NAME"] + ref = os.environ["GITHUB_REF"] + sha = os.environ["GITHUB_SHA"] + repository = os.environ["GITHUB_REPOSITORY"] + pull_requests = [] + if event == "push" and ref in RELEASE_BRANCHES: + pages = json.loads(subprocess.check_output( + ["gh", "api", "--paginate", "--slurp", + f"repos/{repository}/commits/{sha}/pulls?per_page=100"], + text=True, + )) + pull_requests = [pr for page in pages for pr in page] + metadata = release_metadata(version, event, ref, sha, repository, pull_requests) + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + for name, value in metadata.items(): + print(f"{name}={value}", file=output) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/test_release.py b/.github/scripts/test_release.py new file mode 100644 index 0000000..1d190bc --- /dev/null +++ b/.github/scripts/test_release.py @@ -0,0 +1,204 @@ +import copy +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import release + + +SHA = "a" * 40 +REPOSITORY = "csound/plugins" +MERGED_PR = { + "merged_at": "2026-10-29T10:35:04Z", + "merge_commit_sha": SHA, + "base": {"ref": "main", "repo": {"full_name": REPOSITORY}}, + "labels": [{"name": "release"}], +} + + +class VersionTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + + def write_manifests(self, version, lock_version=None, root_version=None): + (self.root / "package.json").write_text(json.dumps({"version": version})) + (self.root / "package-lock.json").write_text(json.dumps({ + "version": lock_version or version, + "packages": {"": {"version": root_version or version}}, + })) + + def test_stable_and_prerelease_versions(self): + for version in ["7.0.0", "7.1.0-beta.2", "7.0.0-rc1", "7.0.0-alpha-beta.0"]: + with self.subTest(version=version): + self.write_manifests(version) + self.assertEqual(release.read_version(self.root), version) + + def test_invalid_versions(self): + for version in [None, 7, "", "v7.0.0", "7.0", "07.0.0", "7.0.0-beta.01", + "7.0.0-", "7.0.0-beta..1", "7.0.0+build", "7.0.0\nrelease=true"]: + with self.subTest(version=version): + self.write_manifests(version) + with self.assertRaisesRegex(ValueError, "SemVer"): + release.read_version(self.root) + + def test_both_lockfile_versions_must_match(self): + for mismatch in [{"lock_version": "7.0.1"}, {"root_version": "7.0.1"}]: + with self.subTest(mismatch=mismatch): + self.write_manifests("7.0.0", **mismatch) + with self.assertRaisesRegex(ValueError, "must match"): + release.read_version(self.root) + + +class ReleaseTests(unittest.TestCase): + def metadata(self, version="7.0.0", event="push", ref="refs/heads/main", prs=None): + return release.release_metadata( + version, event, ref, SHA, REPOSITORY, [MERGED_PR] if prs is None else prs + ) + + def test_labelled_main_merge_releases(self): + self.assertEqual(self.metadata(), { + "version": "7.0.0", "release": "true", "prerelease": "false", + }) + + def test_labelled_develop_merge_prereleases(self): + pr = copy.deepcopy(MERGED_PR) + pr["base"]["ref"] = "develop" + self.assertEqual(self.metadata("7.1.0-beta.2", ref="refs/heads/develop", prs=[pr]), { + "version": "7.1.0-beta.2", "release": "true", "prerelease": "true", + }) + + def test_only_push_events_to_release_branches_can_release(self): + for event, ref in [ + ("pull_request", "refs/heads/main"), + ("pull_request_target", "refs/heads/main"), + ("workflow_dispatch", "refs/heads/main"), + ("push", "refs/heads/topic"), + ("push", "refs/tags/7.0.0"), + ]: + with self.subTest(event=event, ref=ref): + self.assertEqual(self.metadata(event=event, ref=ref)["release"], "false") + + def test_requires_exact_merge_base_repository_and_label(self): + for field, value in [ + ("merged_at", None), + ("merge_commit_sha", "b" * 40), + ("base", {"ref": "develop", "repo": {"full_name": REPOSITORY}}), + ("base", {"ref": "main", "repo": {"full_name": "fork/plugins"}}), + ("labels", []), + ("labels", [{"name": "no-release"}]), + ("labels", [{"name": "release-candidate"}]), + ]: + with self.subTest(field=field, value=value): + pr = copy.deepcopy(MERGED_PR) + pr[field] = value + self.assertEqual(self.metadata(prs=[pr])["release"], "false") + self.assertEqual(self.metadata(prs=[])["release"], "false") + + def test_release_version_must_match_branch(self): + with self.assertRaisesRegex(ValueError, "main needs a stable version"): + self.metadata(version="7.0.0-beta.2") + pr = copy.deepcopy(MERGED_PR) + pr["base"]["ref"] = "develop" + with self.assertRaisesRegex(ValueError, "develop needs a prerelease version"): + self.metadata(ref="refs/heads/develop", prs=[pr]) + + def test_ordinary_builds_allow_both_version_types(self): + for ref in ["refs/heads/main", "refs/heads/develop"]: + for version in ["7.0.0", "7.0.0-beta.2"]: + with self.subTest(ref=ref, version=version): + self.assertEqual(self.metadata(version, ref=ref, prs=[])["release"], "false") + + +class CommandTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.output = Path(directory.name) / "output" + self.output.write_text("previous=value\n") + self.environment = { + "GITHUB_EVENT_NAME": "push", + "GITHUB_REF": "refs/heads/main", + "GITHUB_SHA": SHA, + "GITHUB_REPOSITORY": REPOSITORY, + "GITHUB_OUTPUT": str(self.output), + } + self.addCleanup(patch.stopall) + patch.dict(os.environ, self.environment).start() + patch("sys.argv", ["release.py"]).start() + patch.object(release, "read_version", return_value="7.0.0").start() + + def test_paginated_pr_lookup_and_output(self): + unrelated = copy.deepcopy(MERGED_PR) + unrelated["merge_commit_sha"] = "b" * 40 + pages = json.dumps([[unrelated] * 100, [MERGED_PR]]) + with patch.object(release.subprocess, "check_output", return_value=pages) as api: + release.main() + api.assert_called_once_with([ + "gh", "api", "--paginate", "--slurp", + f"repos/{REPOSITORY}/commits/{SHA}/pulls?per_page=100", + ], text=True) + self.assertEqual(self.output.read_text(), + "previous=value\nversion=7.0.0\nrelease=true\nprerelease=false\n") + + def test_pr_and_manual_runs_do_not_query_api(self): + for event in ["pull_request", "workflow_dispatch"]: + with self.subTest(event=event), patch.dict(os.environ, {"GITHUB_EVENT_NAME": event}): + with patch.object(release.subprocess, "check_output") as api: + release.main() + api.assert_not_called() + self.assertNotIn("release=true", self.output.read_text()) + + def test_api_failure_does_not_write_release_outputs(self): + with patch.object(release.subprocess, "check_output", + side_effect=subprocess.CalledProcessError(1, "gh")): + with self.assertRaises(subprocess.CalledProcessError): + release.main() + self.assertEqual(self.output.read_text(), "previous=value\n") + + def test_tag_check_does_not_query_api_or_write_outputs(self): + with patch("sys.argv", ["release.py", "--check-tag"]): + with patch.object(release, "check_tag") as check: + release.main() + check.assert_called_once_with("7.0.0", SHA) + self.assertEqual(self.output.read_text(), "previous=value\n") + + +class TagTests(unittest.TestCase): + def test_missing_tag_is_allowed(self): + with patch.object(release.subprocess, "run", + return_value=subprocess.CompletedProcess("git", 1)): + with patch.object(release.subprocess, "check_output") as resolve: + release.check_tag("7.0.0", SHA) + resolve.assert_not_called() + + def test_existing_tag_must_resolve_to_release_commit(self): + for target in [SHA, "b" * 40]: + with self.subTest(target=target): + with patch.object(release.subprocess, "run", + return_value=subprocess.CompletedProcess("git", 0)): + with patch.object(release.subprocess, "check_output", + return_value=f"{target}\n") as resolve: + if target == SHA: + release.check_tag("7.0.0", SHA) + else: + with self.assertRaisesRegex(ValueError, "choose a new version"): + release.check_tag("7.0.0", SHA) + resolve.assert_called_once_with( + ["git", "rev-parse", "--verify", "refs/tags/7.0.0^{commit}"], text=True + ) + + def test_git_error_does_not_count_as_missing_tag(self): + with patch.object(release.subprocess, "run", + return_value=subprocess.CompletedProcess("git", 128)): + with self.assertRaises(subprocess.CalledProcessError): + release.check_tag("7.0.0", SHA) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/plugins_build.yml b/.github/workflows/plugins_build.yml index fa13ad7..881e27e 100644 --- a/.github/workflows/plugins_build.yml +++ b/.github/workflows/plugins_build.yml @@ -4,8 +4,8 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} on: push: @@ -25,36 +25,29 @@ env: jobs: update_version: - name: Update plugin version + name: Read release version runs-on: ubuntu-latest permissions: - contents: write + contents: read + pull-requests: read outputs: - version: ${{ steps.tag.outputs.new_tag }} - release: ${{ steps.check_pr.outputs.has_label }} + version: ${{ steps.release.outputs.version }} + release: ${{ steps.release.outputs.release }} + prerelease: ${{ steps.release.outputs.prerelease }} steps: - name: Checkout source uses: actions/checkout@v7.0.1 with: - fetch-depth: 0 + persist-credentials: false + + - name: Check release rules + run: python3 -B -m unittest discover -s .github/scripts -p 'test_*.py' - - name: Compute next version - id: tag - uses: anothrNick/github-tag-action@1.75.0 + - name: Read version and check the merged PR + id: release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEFAULT_BUMP: patch - DEFAULT_BRANCH: main - PRERELEASE: ${{ github.ref != 'refs/heads/main' }} - PRERELEASE_SUFFIX: beta - DRY_RUN: true - - - name: Check for release label - id: check_pr - uses: ./.github/actions/check-pr-label - with: - label: release - github_token: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 .github/scripts/release.py linux: name: Linux (ubuntu-latest) @@ -120,11 +113,13 @@ jobs: - name: Configure plugin build run: | - echo "CSOUND_INCLUDE_DIR_HINT=$HOME/csound-install/include/csound" >> "$GITHUB_ENV" - echo "CSOUND_LIBRARY_DIR_HINT=$HOME/csound-install/lib" >> "$GITHUB_ENV" - echo "FAUST_INCLUDE_DIR_HINT=$HOME/faust/include" >> "$GITHUB_ENV" - echo "FAUST_LIB_DIR_HINT=$HOME/faust/lib" >> "$GITHUB_ENV" - echo "FAUST_STDLIB_DIR_HINT=$HOME/faust/stdlib" >> "$GITHUB_ENV" + { + echo "CSOUND_INCLUDE_DIR_HINT=$HOME/csound-install/include/csound" + echo "CSOUND_LIBRARY_DIR_HINT=$HOME/csound-install/lib" + echo "FAUST_INCLUDE_DIR_HINT=$HOME/faust/include" + echo "FAUST_LIB_DIR_HINT=$HOME/faust/lib" + echo "FAUST_STDLIB_DIR_HINT=$HOME/faust/stdlib" + } >> "$GITHUB_ENV" - name: Build plugins run: | @@ -264,12 +259,14 @@ jobs: if [ ! -f "$headers/csound.h" ]; then headers="$framework/Headers" fi - echo "CSOUND_INCLUDE_DIR_HINT=$headers" >> "$GITHUB_ENV" - echo "CSOUND_FRAMEWORK_DIR_HINT=$HOME/csound-install/Frameworks" >> "$GITHUB_ENV" - echo "CSOUND_LIBRARY_DIR_HINT=$HOME/csound-install/Frameworks" >> "$GITHUB_ENV" - echo "FAUST_INCLUDE_DIR_HINT=$HOME/faust/include" >> "$GITHUB_ENV" - echo "FAUST_LIB_DIR_HINT=$HOME/faust/lib" >> "$GITHUB_ENV" - echo "FAUST_STDLIB_DIR_HINT=$HOME/faust/stdlib" >> "$GITHUB_ENV" + { + echo "CSOUND_INCLUDE_DIR_HINT=$headers" + echo "CSOUND_FRAMEWORK_DIR_HINT=$HOME/csound-install/Frameworks" + echo "CSOUND_LIBRARY_DIR_HINT=$HOME/csound-install/Frameworks" + echo "FAUST_INCLUDE_DIR_HINT=$HOME/faust/include" + echo "FAUST_LIB_DIR_HINT=$HOME/faust/lib" + echo "FAUST_STDLIB_DIR_HINT=$HOME/faust/stdlib" + } >> "$GITHUB_ENV" - name: Build plugins run: | @@ -515,12 +512,24 @@ jobs: publish_release: name: Publish release - if: ${{ (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/main') && needs.update_version.outputs.release == 'true' }} + if: ${{ github.event_name == 'push' && needs.update_version.outputs.release == 'true' }} needs: [update_version, linux, linux_cuda, macos, windows] runs-on: ubuntu-latest + concurrency: + group: plugin-release-${{ needs.update_version.outputs.version }} + cancel-in-progress: false permissions: contents: write steps: + - name: Checkout release commit + uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check existing release tag + run: python3 .github/scripts/release.py --check-tag + - name: Download Linux plugins uses: actions/download-artifact@v8.0.2 with: @@ -558,9 +567,11 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} tag_name: ${{ needs.update_version.outputs.version }} + target_commitish: ${{ github.sha }} name: ${{ needs.update_version.outputs.version }} generate_release_notes: true - prerelease: ${{ github.ref != 'refs/heads/main' }} + prerelease: ${{ needs.update_version.outputs.prerelease == 'true' }} + fail_on_unmatched_files: true files: | csound7-plugins-${{ needs.update_version.outputs.version }}-linux-x86_64.zip csound7-plugins-${{ needs.update_version.outputs.version }}-macos.zip diff --git a/README.md b/README.md index 7f0b072..56559b5 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,34 @@ disabled elsewhere; configure with `-DBUILD_DSSI_OPCODES=OFF` to exclude it. It is included in the Linux release package only. +Releasing +-------------- + +[`package.json`](package.json) holds the plugin version for both native and npm +packages. GitHub release tags and asset names use that version as written, +without a `v` prefix. The workflow's `CSOUND_VERSION` selects the Csound build +used to compile and test native plugins; it does not set the plugin version. + +1. Set the next version with `npm version --no-git-tag-version` and + commit both `package.json` and `package-lock.json`. The workflow checks that + their versions match. Use a stable version for a release from `main`, or a + prerelease version for a release from `develop`. +2. Open a PR into the release branch (normally `develop` into `main`) and add + the `release` label before merging it. +3. Merge the PR. Once all native build jobs pass, the push workflow publishes + the release and tags that exact merge commit. A prerelease version creates + a GitHub prerelease. + +PR builds, manual runs, and pushes without a matching merged PR carrying the +`release` label only build and test. New pushes do not cancel release builds. +To retry a failed release, rerun its original workflow run. A tag that already +points to the same commit is safe to reuse; if it points elsewhere, choose a +new version. The workflow never moves an existing tag. + +npm publication remains a separate manual step; see the +[npm release instructions](wasm/README.md#npm-releases). + + Release binaries and macOS architecture -------------- diff --git a/package-lock.json b/package-lock.json index 0948b95..84d0b48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@csound/plugins", - "version": "7.0.0-beta.1", + "version": "7.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@csound/plugins", - "version": "7.0.0-beta.1", + "version": "7.0.0", "license": "LGPL-2.1-or-later", "devDependencies": { "@csound/browser": "7.0.0-beta41", diff --git a/package.json b/package.json index 2df63e8..168a67c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@csound/plugins", - "version": "7.0.0-beta.1", + "version": "7.0.0", "description": "Csound 7 opcode plugins for WebAssembly", "type": "module", "license": "LGPL-2.1-or-later", diff --git a/wasm/README.md b/wasm/README.md index 60cbc7c..3ad2d0b 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -99,7 +99,9 @@ GitHub Actions caches. ## npm releases -Set the next package version with `npm version`, then run the build, test, and -pack commands against the published browser package with `CSOUND_BROWSER_URL` -unset. Publish with `npm publish --tag beta --access public` from that build. -No workflow publishes this package yet. +Native and npm packages share the version in `package.json`; see the +[release process](../README.md#releasing) for version changes and Git tags. +Run the build, test, and pack commands against the published browser package +with `CSOUND_BROWSER_URL` unset. Publish a prerelease with +`npm publish --tag beta --access public`, or a stable release with +`npm publish --access public`, from that build. No workflow publishes to npm.