diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73b34ef..e3989d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,49 +1,37 @@ -name: Public protocol CI +name: CI on: push: branches: [main] pull_request: - workflow_dispatch: workflow_call: permissions: contents: read concurrency: - group: protocol-ci-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: runs-on: ubuntu-24.04 timeout-minutes: 10 - env: - GOENV: 'off' - GOWORK: 'off' - GOTOOLCHAIN: local steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.27.1' - cache: true - - name: Prepare complete module cache + go-version-file: go.mod + - name: The protocol depends on neither the server nor the SDK and replaces nothing run: | - module_dir=$(mktemp -d "$RUNNER_TEMP/weir-protocol-module.XXXXXX") - cp go.mod go.sum "$module_dir/" - (cd "$module_dir" && go mod download all) - - name: Offline validation - env: - GOPROXY: 'off' - GOSUMDB: 'off' + forbidden=$(go list -m -f '{{if or .Replace (eq .Path "github.com/batchstream/weir") (eq .Path "github.com/batchstream/weir-go")}}{{.Path}}{{end}}' all) + echo "$forbidden" + test -z "$forbidden" + - name: Tests, vet and formatting run: | - go mod verify + go mod tidy -diff go test -race -count=1 -timeout=90s ./... go vet ./... - python3 scripts/check_dependencies.py - python3 -m unittest discover -s scripts -p '*_test.py' -v test -z "$(gofmt -l .)" - git diff --exit-code diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cded6ce..5a0b8c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,10 @@ -name: Publish protocol version +name: Release on: workflow_dispatch: inputs: version: - description: Stable semantic version + description: Stable version (vMAJOR.MINOR.PATCH) required: true type: string @@ -16,25 +16,24 @@ jobs: if: github.ref == 'refs/heads/main' uses: ./.github/workflows/ci.yml - publish: + release: needs: validate runs-on: ubuntu-24.04 timeout-minutes: 5 concurrency: - group: protocol-release + group: release cancel-in-progress: false permissions: contents: write steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - name: Check version and exact main commit - env: - VERSION: ${{ inputs.version }} - run: python3 scripts/release.py --version "$VERSION" --expected-commit "$GITHUB_SHA" - - name: Publish validated commit + - name: Tag and release the validated commit env: GH_TOKEN: ${{ github.token }} VERSION: ${{ inputs.version }} - run: gh release create "$VERSION" --target "$GITHUB_SHA" --title "$VERSION" --generate-notes + run: | + [[ $VERSION =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + # An existing tag is accepted only at this commit, so a rerun can finish a release. + if ! gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$VERSION" -f sha="$GITHUB_SHA" >/dev/null; then + test "$(gh api "repos/$GITHUB_REPOSITORY/commits/$VERSION" --jq .sha)" = "$GITHUB_SHA" + fi + gh release create "$VERSION" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$VERSION" --generate-notes diff --git a/.gitignore b/.gitignore index 272d3c3..bbb5666 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -__pycache__/ -*.pyc coverage.out go.work go.work.sum diff --git a/README.md b/README.md index 70deaaf..358db76 100644 --- a/README.md +++ b/README.md @@ -131,28 +131,14 @@ follow the Lua contract above. ## Validate ```sh -module_dir=$(mktemp -d) -cp go.mod go.sum "$module_dir/" -(cd "$module_dir" && GOWORK=off go mod download all) -GOWORK=off GOPROXY=off GOSUMDB=off go mod verify -GOWORK=off GOPROXY=off GOSUMDB=off go test -race -count=1 ./... -GOWORK=off GOPROXY=off GOSUMDB=off go vet ./... -GOWORK=off GOPROXY=off GOSUMDB=off python3 scripts/check_dependencies.py -python3 -m unittest discover -s scripts -p '*_test.py' +go test -race ./... +go vet ./... ``` -Prepare the complete module cache in a temporary module copy before disabling -network access. `download all` includes graph/test metadata that lazy runtime -downloads omit; its extra ZIP checksums stay outside the committed `go.sum`. - Default tests use in-memory protocol data. They do not contact external -endpoints or start databases. Descriptor tests -protect the public RPC and field contract. -The dependency check examines every selected module, including transitive and -test requirements, rejects Server/SDK dependencies and replacements, examines -all raw module-graph edges including versioned protocol return edges, then checks -the complete production and test package graph. It forces workspace mode off -for every Go subprocess so unrelated checkouts cannot mask module boundaries. +endpoints or start databases. Descriptor tests protect the public RPC and field +contract. CI also checks that the module graph contains neither the server nor +the SDK and that `go.mod` replaces no module. ## Generate @@ -184,9 +170,7 @@ After the reviewed change is merged, an authorized maintainer can publish: gh workflow run release.yml --ref main -f version=vX.Y.Z ``` -The workflow accepts only stable `vMAJOR.MINOR.PATCH` versions and runs the complete -offline CI before publication. Its read-only preflight requires a clean checkout, -the validated commit to still be the current remote `main`, and no existing tag -for that version. It then creates the GitHub release and tag at that exact commit. -The preflight script itself never publishes, tags, or pushes. Protocol consumers -should pin the published version rather than a branch name. +The workflow accepts only stable `vMAJOR.MINOR.PATCH` versions, runs CI on the +`main` commit, then creates the version tag at that commit and its GitHub release. +It refuses a version already tagged at another commit. Protocol consumers should +pin the published version rather than a branch name. diff --git a/scripts/check_dependencies.py b/scripts/check_dependencies.py deleted file mode 100644 index 2904035..0000000 --- a/scripts/check_dependencies.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 -"""Enforce the standalone public module's production and test dependency graph.""" -import json -import os -from pathlib import Path -import subprocess - -PROTOCOL_MODULE = 'github.com/batchstream/weir-protocol' -FORBIDDEN_MODULES = {'github.com/batchstream/weir', 'github.com/batchstream/weir-go'} - - -def objects(raw): - decoder = json.JSONDecoder() - result = [] - while raw.strip(): - value, end = decoder.raw_decode(raw.lstrip()) - result.append(value) - raw = raw.lstrip()[end:] - return result - - -def check_modules(modules): - for module in modules: - name = module['Path'] - if name in FORBIDDEN_MODULES: - raise ValueError('protocol module depends on Server or SDK: ' + name) - if module.get('Replace'): - raise ValueError('protocol module must not use replacements: ' + name) - - -def check_graph(raw): - for line in raw.splitlines(): - edge = line.split() - if len(edge) != 2: - raise ValueError('invalid module graph edge') - for node in edge: - module = node.split('@', 1)[0] - if module in FORBIDDEN_MODULES: - raise ValueError('raw protocol graph depends on Server or SDK: ' + node) - if module == PROTOCOL_MODULE and '@' in node: - raise ValueError('raw protocol graph returns to a versioned protocol module: ' + node) - - -def check_packages(packages): - for package in packages: - name = package['ImportPath'] - if any(name == module or name.startswith(module + '/') for module in FORBIDDEN_MODULES): - raise ValueError('protocol package imports Server or SDK: ' + name) - - -def main(): - root = Path(__file__).resolve().parent.parent - go_env = dict(os.environ, GOWORK='off') - raw_modules = subprocess.check_output(['go', 'list', '-m', '-json', 'all'], cwd=root, env=go_env, text=True, timeout=120) - check_modules(objects(raw_modules)) - raw_graph = subprocess.check_output(['go', 'mod', 'graph'], cwd=root, env=go_env, text=True, timeout=120) - check_graph(raw_graph) - raw_packages = subprocess.check_output(['go', 'list', '-deps', '-test', '-json', './...'], cwd=root, env=go_env, text=True, timeout=120) - check_packages(objects(raw_packages)) - print('Standalone protocol production/test module and package graph passed') - - -if __name__ == '__main__': - main() diff --git a/scripts/check_dependencies_test.py b/scripts/check_dependencies_test.py deleted file mode 100644 index 27db333..0000000 --- a/scripts/check_dependencies_test.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Offline dependency checker tests; no Go, GitHub, or module requests.""" -import json -import os -import unittest -from unittest.mock import patch - -import check_dependencies - - -class DependencyTests(unittest.TestCase): - def test_protocol_and_runtime_modules_are_allowed(self): - modules = [{'Path': path} for path in ('github.com/batchstream/weir-protocol', 'google.golang.org/grpc', 'golang.org/x/net')] - check_dependencies.check_modules(modules) - - def test_any_server_or_sdk_requirement_is_rejected(self): - for path in check_dependencies.FORBIDDEN_MODULES: - modules = [{'Path': 'github.com/batchstream/weir-protocol'}, {'Path': path, 'Indirect': True}] - with self.assertRaisesRegex(ValueError, 'Server or SDK'): - check_dependencies.check_modules(modules) - - def test_any_replacement_is_rejected(self): - modules = [{'Path': 'example.com/runtime', 'Replace': {'Dir': '/local'}}] - with self.assertRaisesRegex(ValueError, 'replacements'): - check_dependencies.check_modules(modules) - - def test_test_packages_cannot_import_server_or_sdk(self): - for path in ('github.com/batchstream/weir/internal/testutil', 'github.com/batchstream/weir-go/internal/testfixture', 'github.com/batchstream/weir/api/protocol'): - packages = [{'ImportPath': path}] - with self.assertRaisesRegex(ValueError, 'Server or SDK'): - check_dependencies.check_packages(packages) - - def test_own_api_is_allowed(self): - packages = [{'ImportPath': path} for path in ('github.com/batchstream/weir-protocol/api/protocol', 'github.com/batchstream/weir-protocol/api/weir/v1', 'context')] - check_dependencies.check_packages(packages) - - def test_raw_graph_rejects_server_and_sdk_edges(self): - for edge in ( - 'example.com/dep@v1.0.0 github.com/batchstream/weir@v0.1.0', - 'github.com/batchstream/weir-go@v0.1.0 example.com/dep@v2.0.0', - 'github.com/batchstream/weir-protocol github.com/batchstream/weir-go@v0.0.1', - ): - with self.assertRaisesRegex(ValueError, 'Server or SDK'): - check_dependencies.check_graph(edge) - - def test_raw_graph_rejects_versioned_protocol_return_edges(self): - for edge in ( - 'example.com/dep@v1.0.0 github.com/batchstream/weir-protocol@v0.0.1', - 'github.com/batchstream/weir-protocol@v0.0.1 example.com/dep@v1.0.0', - ): - with self.assertRaisesRegex(ValueError, 'versioned protocol'): - check_dependencies.check_graph(edge) - - def test_raw_graph_accepts_root_and_external_versions(self): - graph = 'github.com/batchstream/weir-protocol google.golang.org/grpc@v1.83.2\ngoogle.golang.org/grpc@v1.83.2 google.golang.org/protobuf@v1.36.11\n' - check_dependencies.check_graph(graph) - - def test_go_subprocesses_disable_an_inherited_workspace(self): - modules = json.dumps({'Path': check_dependencies.PROTOCOL_MODULE}) - graph = check_dependencies.PROTOCOL_MODULE + ' google.golang.org/grpc@v1.83.2' - packages = json.dumps({'ImportPath': check_dependencies.PROTOCOL_MODULE + '/api/protocol'}) - environment = {'GOWORK': '/unrelated/go.work'} - with patch.dict(os.environ, environment), patch.object(check_dependencies.subprocess, 'check_output', side_effect=[modules, graph, packages]) as command: - check_dependencies.main() - self.assertEqual(command.call_count, 3) - for call in command.call_args_list: - self.assertEqual(call.kwargs['env']['GOWORK'], 'off') - - def test_go_json_objects_decode_without_wrapper_array(self): - expected = [{'Path': 'github.com/batchstream/weir-protocol'}, {'Path': 'google.golang.org/grpc'}] - raw = '\n'.join(json.dumps(item) for item in expected) - self.assertEqual(check_dependencies.objects(raw), expected) - - -if __name__ == '__main__': - unittest.main() diff --git a/scripts/release.py b/scripts/release.py deleted file mode 100644 index 71ddf6c..0000000 --- a/scripts/release.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -"""Read-only preflight for publishing a stable version from the validated main commit.""" -import argparse -from pathlib import Path -import re -import subprocess - - -VERSION = re.compile(r'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)') -COMMIT = re.compile(r'[0-9a-f]{40}') - - -def check_version(version): - if not VERSION.fullmatch(version): - raise ValueError('release version must be a stable vMAJOR.MINOR.PATCH without leading zeros') - - -def check_refs(raw, version, expected_commit): - refs = {} - for line in raw.splitlines(): - fields = line.split() - if len(fields) != 2 or not COMMIT.fullmatch(fields[0]): - raise ValueError('invalid remote Git reference') - refs[fields[1]] = fields[0] - if refs.get('refs/heads/main') != expected_commit: - raise ValueError('remote main changed or does not match the validated commit') - if 'refs/tags/' + version in refs: - raise ValueError('release version already has a remote tag') - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--version', required=True) - parser.add_argument('--expected-commit', required=True) - args = parser.parse_args() - check_version(args.version) - if not COMMIT.fullmatch(args.expected_commit): - raise ValueError('expected commit must be a full Git SHA') - root = Path(__file__).resolve().parent.parent - head = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=root, text=True, timeout=30).strip() - if head != args.expected_commit: - raise ValueError('checkout does not match the validated commit') - status = subprocess.check_output(['git', 'status', '--porcelain'], cwd=root, text=True, timeout=30) - if status: - raise ValueError('release requires a clean worktree') - refs = subprocess.check_output(['git', 'ls-remote', 'origin', 'refs/heads/main', 'refs/tags/' + args.version], cwd=root, text=True, timeout=30) - check_refs(refs, args.version, args.expected_commit) - print('Stable release preflight passed for ' + args.version + ' at ' + head) - - -if __name__ == '__main__': - try: - main() - except (ValueError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: - raise SystemExit(str(error)) diff --git a/scripts/release_test.py b/scripts/release_test.py deleted file mode 100644 index fad2586..0000000 --- a/scripts/release_test.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Release preflight tests without publishing tags or contacting remote services.""" -import sys -import unittest -from unittest.mock import patch - -import release - - -COMMIT = 'a' * 40 - - -class ReleaseTests(unittest.TestCase): - def test_stable_versions(self): - for version in ('v0.1.0', 'v1.0.0', 'v12.34.56'): - release.check_version(version) - - def test_nonstable_or_ambiguous_versions_are_rejected(self): - for version in ('main', '0.1.0', 'v0.1', 'v0.1.0-rc.1', 'v0.1.0+build', 'v01.1.0', 'v0.01.0', 'v0.1.00', 'v0.1.0\n', 'v0.1.0;command'): - with self.assertRaises(ValueError): - release.check_version(version) - - def test_exact_current_main_without_existing_tag(self): - release.check_refs(COMMIT + '\trefs/heads/main\n', 'v0.1.0', COMMIT) - - def test_missing_or_changed_main_is_rejected(self): - for refs in ('', 'b' * 40 + '\trefs/heads/main\n'): - with self.assertRaisesRegex(ValueError, 'remote main'): - release.check_refs(refs, 'v0.1.0', COMMIT) - - def test_existing_remote_version_is_rejected(self): - refs = COMMIT + '\trefs/heads/main\n' + 'b' * 40 + '\trefs/tags/v0.1.0\n' - with self.assertRaisesRegex(ValueError, 'already has'): - release.check_refs(refs, 'v0.1.0', COMMIT) - - def test_invalid_remote_output_is_rejected(self): - for refs in ('invalid', 'HEAD refs/heads/main', COMMIT + ' refs/heads/main extra'): - with self.assertRaisesRegex(ValueError, 'invalid remote'): - release.check_refs(refs, 'v0.1.0', COMMIT) - - def test_preflight_only_runs_read_only_git_commands(self): - args = ['release.py', '--version', 'v0.1.0', '--expected-commit', COMMIT] - replies = [COMMIT + '\n', '', COMMIT + '\trefs/heads/main\n'] - with patch.object(sys, 'argv', args), patch.object(release.subprocess, 'check_output', side_effect=replies) as command: - release.main() - self.assertEqual(command.call_count, 3) - self.assertEqual(command.call_args_list[0].args[0], ['git', 'rev-parse', 'HEAD']) - self.assertEqual(command.call_args_list[1].args[0], ['git', 'status', '--porcelain']) - self.assertEqual(command.call_args_list[2].args[0], ['git', 'ls-remote', 'origin', 'refs/heads/main', 'refs/tags/v0.1.0']) - - def test_invalid_version_stops_before_git(self): - args = ['release.py', '--version', 'main', '--expected-commit', COMMIT] - with patch.object(sys, 'argv', args), patch.object(release.subprocess, 'check_output') as command: - with self.assertRaisesRegex(ValueError, 'stable'): - release.main() - command.assert_not_called() - - def test_invalid_commit_stops_before_git(self): - args = ['release.py', '--version', 'v0.1.0', '--expected-commit', 'abc'] - with patch.object(sys, 'argv', args), patch.object(release.subprocess, 'check_output') as command: - with self.assertRaisesRegex(ValueError, 'full Git SHA'): - release.main() - command.assert_not_called() - - def test_dirty_checkout_stops_before_remote_lookup(self): - args = ['release.py', '--version', 'v0.1.0', '--expected-commit', COMMIT] - with patch.object(sys, 'argv', args), patch.object(release.subprocess, 'check_output', side_effect=[COMMIT + '\n', ' M source.go\n']) as command: - with self.assertRaisesRegex(ValueError, 'clean worktree'): - release.main() - self.assertEqual(command.call_count, 2) - - def test_wrong_checkout_stops_before_remote_lookup(self): - args = ['release.py', '--version', 'v0.1.0', '--expected-commit', COMMIT] - with patch.object(sys, 'argv', args), patch.object(release.subprocess, 'check_output', return_value='b' * 40 + '\n') as command: - with self.assertRaisesRegex(ValueError, 'checkout'): - release.main() - self.assertEqual(command.call_count, 1) - - -if __name__ == '__main__': - unittest.main()