From 1a25b8be4d7fbf6441d42c59118f836b29522541 Mon Sep 17 00:00:00 2001 From: Randy <857271370@qq.com> Date: Thu, 29 Oct 2026 17:41:23 +0800 Subject: [PATCH 1/2] =?UTF-8?q?build:=20=F0=9F=93=A6=EF=B8=8F=20validate?= =?UTF-8?q?=20and=20release=20with=20standard=20Go=20and=20GitHub=20toolin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 34 ++++--------- .github/workflows/release.yml | 23 ++++----- .gitignore | 2 - README.md | 34 ++++--------- scripts/check_dependencies.py | 64 ------------------------ scripts/check_dependencies_test.py | 75 ---------------------------- scripts/release.py | 55 -------------------- scripts/release_test.py | 80 ------------------------------ 8 files changed, 30 insertions(+), 337 deletions(-) delete mode 100644 scripts/check_dependencies.py delete mode 100644 scripts/check_dependencies_test.py delete mode 100644 scripts/release.py delete mode 100644 scripts/release_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73b34ef..9458d0f 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 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 (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..6f14bb6 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,22 @@ 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]*)$ ]] + # Creating the tag fails when the version already exists. + gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$VERSION" -f sha="$GITHUB_SHA" >/dev/null + 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..4e6d2fd 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. ## 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. +Creating the tag fails when the version already exists. 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() From 8c8ca9278daa5794305b1c0623faba9ee2b80a28 Mon Sep 17 00:00:00 2001 From: Randy <857271370@qq.com> Date: Thu, 29 Oct 2026 17:50:03 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=F0=9F=90=9B=20reject=20replace=20di?= =?UTF-8?q?rectives=20and=20let=20a=20release=20rerun=20finish=20at=20its?= =?UTF-8?q?=20own=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 6 ++++-- README.md | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9458d0f..e3989d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,9 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - - name: The protocol depends on neither the server nor the SDK + - name: The protocol depends on neither the server nor the SDK and replaces nothing run: | - forbidden=$(go list -m -f '{{if or (eq .Path "github.com/batchstream/weir") (eq .Path "github.com/batchstream/weir-go")}}{{.Path}}{{end}}' all) + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f14bb6..5a0b8c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,8 @@ jobs: VERSION: ${{ inputs.version }} run: | [[ $VERSION =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] - # Creating the tag fails when the version already exists. - gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$VERSION" -f sha="$GITHUB_SHA" >/dev/null + # 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/README.md b/README.md index 4e6d2fd..358db76 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ go vet ./... 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. CI also checks that the module graph contains neither the server nor -the SDK. +the SDK and that `go.mod` replaces no module. ## Generate @@ -172,5 +172,5 @@ gh workflow run release.yml --ref main -f version=vX.Y.Z 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. -Creating the tag fails when the version already exists. Protocol consumers should +It refuses a version already tagged at another commit. Protocol consumers should pin the published version rather than a branch name.