From 8934d566c16a937cf776acb7dd64cfadc33022ed Mon Sep 17 00:00:00 2001 From: eezd Date: Tue, 27 Oct 2026 14:47:25 +0800 Subject: [PATCH] ci(release): test and package five native platforms in one workflow --- .github/workflows/release.yml | 51 ++++++++++++++++++++++-------- scripts/package-release.py | 21 +++++++++++++ scripts/smoke-release.py | 58 ++++++++++++++++++++++++++++------- 3 files changed, 106 insertions(+), 24 deletions(-) create mode 100644 scripts/package-release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfb6d38..f22b008 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,18 +55,39 @@ jobs: with: path: web/dist-demo - linux: + native: needs: check strategy: fail-fast: false matrix: include: - - arch: amd64 + - os: linux + arch: amd64 runner: ubuntu-24.04 - - arch: arm64 + extension: '' + - os: linux + arch: arm64 runner: ubuntu-24.04-arm + extension: '' + - os: windows + arch: amd64 + runner: windows-2025 + extension: '.exe' + - os: darwin + arch: arm64 + runner: macos-15 + extension: '' + - os: darwin + arch: amd64 + runner: macos-15-intel + extension: '' runs-on: ${{ matrix.runner }} timeout-minutes: 30 + defaults: + run: + shell: bash + env: + CGO_ENABLED: '0' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -77,30 +98,34 @@ jobs: name: frontend path: web/dist - name: Build and smoke-test native binary + env: + EXE: ${{ matrix.extension }} run: | mkdir -p package/licenses - CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.releaseVersion=$(cat VERSION)" -o package/rawmark ./cmd/rawmark - go test ./cmd/rawmark ./web - python3 scripts/smoke-release.py package/rawmark + go build -trimpath -ldflags="-s -w -X main.releaseVersion=$(cat VERSION)" -o "package/rawmark$EXE" ./cmd/rawmark + go vet ./... + go test ./internal/... ./cmd/rawmark ./web + python scripts/smoke-release.py "package/rawmark$EXE" - name: Package download env: ARCH: ${{ matrix.arch }} + TARGET_OS: ${{ matrix.os }} run: | cp LICENSE package/ cp web/LICENSE package/licenses/vue3-starter-MIT.txt cp web/src/common/appearance/fonts/*-OFL.txt package/licenses/ cp web/src/common/appearance/fonts/maple/LICENSE.txt package/licenses/Maple-Mono-OFL.txt mkdir downloads - tar -C package -czf "downloads/rawmark_$(cat VERSION)_linux_$ARCH.tar.gz" rawmark LICENSE licenses + python scripts/package-release.py "$TARGET_OS" "$ARCH" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: linux-${{ matrix.arch }} - path: downloads/*.tar.gz + name: native-${{ matrix.os }}-${{ matrix.arch }} + path: downloads/* if-no-files-found: error retention-days: 3 image: - needs: linux + needs: native runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: @@ -112,7 +137,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - pattern: linux-* + pattern: native-linux-* path: downloads merge-multiple: true - name: Prepare tested binaries @@ -186,7 +211,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - pattern: linux-* + pattern: native-* path: downloads merge-multiple: true - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 @@ -201,6 +226,6 @@ jobs: RELEASE_TAG: ${{ github.ref_name }} IMAGE_DIGEST: ${{ needs.image.outputs.digest }} run: | - (cd downloads && sha256sum *.tar.gz > SHA256SUMS) + (cd downloads && sha256sum *.tar.gz *.zip > SHA256SUMS) gh release create "$RELEASE_TAG" downloads/* --verify-tag --title "RawMark $RELEASE_TAG" --notes-file .github/release-notes.md --latest docker buildx imagetools create --tag "ghcr.io/eezd/rawmark:$(cat VERSION)" --tag ghcr.io/eezd/rawmark:latest "ghcr.io/eezd/rawmark@$IMAGE_DIGEST" diff --git a/scripts/package-release.py b/scripts/package-release.py new file mode 100644 index 0000000..227e394 --- /dev/null +++ b/scripts/package-release.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Package the already-tested native executable and licenses for a release.""" + +from pathlib import Path +import sys +import tarfile +import zipfile + +system, arch = sys.argv[1:] +version = Path('VERSION').read_text().strip() +name = f'rawmark_{version}_{system}_{arch}' +package = Path('package') +files = sorted(p for p in package.rglob('*') if p.is_file()) +if system == 'windows': + with zipfile.ZipFile(f'downloads/{name}.zip', 'w', compression=zipfile.ZIP_DEFLATED) as archive: + for file in files: + archive.write(file, file.relative_to(package).as_posix()) +else: + with tarfile.open(f'downloads/{name}.tar.gz', 'w:gz') as archive: + for file in files: + archive.add(file, arcname=file.relative_to(package).as_posix()) diff --git a/scripts/smoke-release.py b/scripts/smoke-release.py index 0c9b717..034cec6 100644 --- a/scripts/smoke-release.py +++ b/scripts/smoke-release.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Exercise a release binary using disposable data only, on either Linux architecture.""" +"""Exercise a release binary using disposable data only, on every supported native platform.""" import contextlib import hashlib @@ -23,7 +23,7 @@ assert subprocess.check_output([binary, '--version'], text=True).strip() == f'Ra with tempfile.TemporaryDirectory(prefix='rawmark-release-') as directory: - data = Path(directory) / 'data' + data = Path(directory) / '数据 data' with socket.socket() as listener: listener.bind(('127.0.0.1', 0)) port = listener.getsockname()[1] @@ -31,11 +31,11 @@ with tempfile.TemporaryDirectory(prefix='rawmark-release-') as directory: environment = {**os.environ, 'RAWMARK_DATA': str(data), 'RAWMARK_VAULT': str(data / 'vault'), 'RAWMARK_ADDR': f'127.0.0.1:{port}'} - def request(path, *, method='GET', body=None, token=None, status=200): - headers = {'Accept-Language': 'en'} + def request(path, *, method='GET', body=None, token=None, status=200, raw=False, headers=None): + headers = {'Accept-Language': 'en', **(headers or {})} if token: headers['Authorization'] = 'Bearer ' + token - if body is not None: + if body is not None and not isinstance(body, bytes): headers['Content-Type'] = 'application/json' body = json.dumps(body).encode() req = urllib.request.Request(origin + '/api/v1/' + path, data=body, headers=headers, method=method) @@ -45,13 +45,15 @@ with tempfile.TemporaryDirectory(prefix='rawmark-release-') as directory: response = error with response: assert response.status == status, (path, response.status, response.read()) + if raw: + return response.read() result = json.load(response) assert (result['code'] == 0) == (status < 400), result return result['data'] @contextlib.contextmanager def running(): - with (Path(directory) / 'server.log').open('w+') as log: + with (Path(directory) / 'server.log').open('w+', encoding='utf-8') as log: process = subprocess.Popen([binary], env=environment, stdout=log, stderr=log) try: for _ in range(100): @@ -86,10 +88,18 @@ with tempfile.TemporaryDirectory(prefix='rawmark-release-') as directory: assert (data / 'vault' / note.relative_to('deploy/data/vault')).read_bytes() == note.read_bytes() path = urllib.parse.quote('Getting Started/Welcome to RawMark.md') original = (data / 'vault/Getting Started/Welcome to RawMark.md').read_bytes() - req = urllib.request.Request(origin + '/api/v1/files/' + path, data=original + b'\nRelease smoke test.\n', - headers={'Authorization': 'Bearer ' + token}, method='PUT') - with urllib.request.urlopen(req, timeout=5) as response: - assert json.load(response)['code'] == 0 + request('files/' + path, method='PUT', body=original + b'\nRelease smoke test.\n', token=token) + note = '中文 folder/笔记 note.md' + moved = '移动 folder/新笔记 note.md' + content = '# Portable note\n\n中文 content.\n'.encode() + request('files/' + urllib.parse.quote(note), method='PUT', body=content, token=token) + request('files/' + urllib.parse.quote(note), method='PUT', body=content + b'Updated.\n', token=token) + request('move', method='POST', body={'from': note, 'to': moved}, token=token) + assert not (data / 'vault' / note).exists() + item = request('files/' + urllib.parse.quote(moved), method='DELETE', token=token) + assert item['id'] in [entry['id'] for entry in request('trash', token=token)['items']] + request('trash/' + item['id'] + '/restore', method='POST', token=token) + assert (data / 'vault' / moved).read_bytes() == content + b'Updated.\n' before = {str(p.relative_to(data / 'vault')): hashlib.sha256(p.read_bytes()).hexdigest() for p in (data / 'vault').rglob('*') if p.is_file()} @@ -99,4 +109,30 @@ with tempfile.TemporaryDirectory(prefix='rawmark-release-') as directory: after = {str(p.relative_to(data / 'vault')): hashlib.sha256(p.read_bytes()).hexdigest() for p in (data / 'vault').rglob('*') if p.is_file()} assert before == after, 'Restart modified existing notes' - print(f'RawMark {version}: initialization, authentication, examples, editing and restart passed') + archive = request('maintenance/export/full', method='POST', token=token, raw=True) + restored_content = (data / 'vault' / moved).read_bytes() + request('files/' + urllib.parse.quote(moved), method='PUT', body=b'Changed after backup', token=token) + upload = request('maintenance/uploads', method='POST', body={'kind': 'full', 'size': len(archive)}, + token=token, status=201)['id'] + for offset in range(0, len(archive), 8 << 20): + chunk = archive[offset:offset + (8 << 20)] + result = request('maintenance/uploads/' + upload, method='PUT', body=chunk, token=token, + headers={'Upload-Offset': str(offset)}) + assert result['offset'] == offset + len(chunk) + request('maintenance/uploads/' + upload + '/validate', method='POST', token=token) + request('maintenance/restore', method='POST', body={'id': upload, 'confirmed': True}, token=token, status=202) + for _ in range(200): + if ((data / 'vault' / moved).is_file() + and (data / 'vault' / moved).read_bytes() == restored_content + and not (data / '.maintenance/restore.json').exists()): + try: + request('health') + break + except urllib.error.URLError: + pass + time.sleep(0.1) + else: + raise TimeoutError('Full restore did not complete') + request('auth/me', token=token, status=401) + print(f'RawMark {version}: initialization, authentication, examples, overwrite, Unicode paths, ' + 'move, recycle bin, restart and full backup/restore passed')