diff --git a/.github/release-tools/.gitignore b/.github/release-tools/.gitignore new file mode 100644 index 0000000000..c18dd8d83c --- /dev/null +++ b/.github/release-tools/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/.github/release-tools/README.md b/.github/release-tools/README.md new file mode 100644 index 0000000000..6126de34fe --- /dev/null +++ b/.github/release-tools/README.md @@ -0,0 +1,30 @@ +# Release matrix + +The release workflow builds the frontend once, then runs each configured Go target on its own Linux runner. `CGO_ENABLED=0` permits cross-compilation. The target matrix is read from `.goreleaser.yaml`, including its exclusions; simple releases select Linux amd64 only. + +Each build uses GoReleaser OSS in snapshot mode with the selected release version and one target. Archive naming, bundled files, Go flags and release templates remain in the existing GoReleaser configurations. Every archive is accompanied by its source commit, target, version and SHA256. The publishing job verifies the complete matrix before building images or publishing. It uses GoReleaser's `extra_files` support to publish existing archives and checksums, with builds disabled. No Pro license is needed. + +Go caches are isolated by target and refreshed on each source commit, with fallback to the preceding target cache. Save uses the original restore key, even if a build hook changes `go.sum`. Matrix jobs upload uniquely named artifacts. The publishing job extracts only the regular Linux binary from each verified archive and restores its executable permission before constructing Docker contexts. QEMU remains limited to runtime-image instructions. DockerHub images are omitted when its credentials are absent; GHCR is always retained. Simple mode still publishes only the amd64 GHCR image and the simple release description. + +All build jobs use the commit resolved by `prepare`, including a manual release's selected tag. Helper scripts come from the workflow revision and are passed as a run-local artifact, so older application tags do not need to contain the new scripts. The workflow serializes release runs to prevent simultaneous updates to moving image tags. + +## Validate without publication + +From a branch containing this workflow: + +```bash +gh workflow run release.yml --ref \ + -f tag= -f dry_run=true -f simple_release=false +``` + +A dry run builds all selected archives and both runtime images, verifies artifact provenance and produces the final checksum file. It exports images locally as OCI archives instead of pushing them. It skips registry logins, GitHub Release publication, DockerHub description updates, Telegram notifications and VERSION synchronization. Test the simple path separately with `simple_release=true`. + +Dry-run artifacts are available in the Actions run, including `release-dry-run-report`. Compare job start/end times, GoReleaser's build duration and cache restore results. Do not present an initial cold-cache run as a warmed-cache benchmark; publishing network time is not measured by dry runs. + +Helper checks: + +```bash +python -m pip install -r .github/release-tools/requirements-release.txt +python -m unittest discover -s .github/release-tools -p 'test_release_matrix.py' +bash -n .github/release-tools/release-images.sh +``` diff --git a/.github/release-tools/release-images.sh b/.github/release-tools/release-images.sh new file mode 100644 index 0000000000..532d176c89 --- /dev/null +++ b/.github/release-tools/release-images.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${RELEASE_VERSION:?}" "${RELEASE_SHA:?}" "${GITHUB_REPOSITORY:?}" "${RUNNER_TEMP:?}" +owner=${GITHUB_REPOSITORY%%/*} +registries=("ghcr.io/${owner,,}/sub2api") +if [[ ${SIMPLE_RELEASE:-false} != true && ${DOCKERHUB_USERNAME:-skip} != skip ]]; then + registries+=("${DOCKERHUB_USERNAME}/sub2api") +fi +arches=(amd64 arm64) +if [[ ${SIMPLE_RELEASE:-false} == true ]]; then arches=(amd64); fi +for arch in "${arches[@]}"; do + args=(--platform "linux/$arch" --file ".release-context/$arch/Dockerfile" + --label "org.opencontainers.image.version=$RELEASE_VERSION" + --label "org.opencontainers.image.revision=$RELEASE_SHA" + --label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY") + for registry in "${registries[@]}"; do + args+=(--tag "$registry:$RELEASE_VERSION-$arch") + if [[ ${SIMPLE_RELEASE:-false} == true ]]; then + args+=(--tag "$registry:$RELEASE_VERSION" --tag "$registry:latest") + fi + done + if [[ ${DRY_RUN:-false} == true ]]; then + args+=(--output "type=oci,dest=$RUNNER_TEMP/sub2api-$arch.oci.tar") + else + args+=(--push) + fi + docker buildx build "${args[@]}" ".release-context/$arch" +done +if [[ ${DRY_RUN:-false} != true && ${SIMPLE_RELEASE:-false} != true ]]; then + major=${RELEASE_VERSION%%.*} + minor=${RELEASE_VERSION#*.}; minor=${minor%%.*} + for registry in "${registries[@]}"; do + docker buildx imagetools create \ + --tag "$registry:$RELEASE_VERSION" --tag "$registry:latest" \ + --tag "$registry:$major.$minor" --tag "$registry:$major" \ + "$registry:$RELEASE_VERSION-amd64" "$registry:$RELEASE_VERSION-arm64" + done +fi diff --git a/.github/release-tools/release_matrix.py b/.github/release-tools/release_matrix.py new file mode 100644 index 0000000000..4ea183879f --- /dev/null +++ b/.github/release-tools/release_matrix.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Split GoReleaser builds across runners without requiring GoReleaser Pro.""" +import argparse +import hashlib +import itertools +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tarfile +from datetime import datetime, timezone + +import yaml + +FULL_CONFIG = Path('.goreleaser.yaml') +SIMPLE_CONFIG = Path('.goreleaser.simple.yaml') +VERSION_FILE = Path('backend/cmd/server/VERSION') +VERSION_RE = re.compile(r'\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?') + + +def config(simple=False): + return yaml.safe_load((SIMPLE_CONFIG if simple else FULL_CONFIG).read_text()) + + +def targets(simple=False): + build = config()['builds'][0] + result = [] + for goos, goarch in itertools.product(build['goos'], build['goarch']): + item = {'goos': goos, 'goarch': goarch} + if any(all(item.get(k) == v for k, v in rule.items()) for rule in build.get('ignore', [])): + continue + if not simple or item == {'goos': 'linux', 'goarch': 'amd64'}: + result.append(item) + if not result: + raise ValueError('empty release target matrix') + return result + + +def archive_name(version, target): + if not VERSION_RE.fullmatch(version) or target not in targets(): + raise ValueError('invalid release version or target') + suffix = 'zip' if target['goos'] == 'windows' else 'tar.gz' + return f"sub2api_{version}_{target['goos']}_{target['goarch']}.{suffix}" + + +def sha256(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def plan(args): + sha = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() + if args.dry_run: + version = VERSION_FILE.read_text().strip() + tag = 'v' + version + else: + tag = args.ref + version = tag.removeprefix('v') + if not tag.startswith('v') or not VERSION_RE.fullmatch(version): + raise ValueError('publishing requires a v-prefixed release version tag') + tagged_sha = subprocess.check_output(['git', 'rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'], text=True).strip() + if sha != tagged_sha: + raise ValueError('checkout does not match the selected release tag') + if not VERSION_RE.fullmatch(version): + raise ValueError('invalid VERSION') + VERSION_FILE.write_text(version + '\n') + result = {'sha': sha, 'tag': tag, 'version': version, + 'owner_lower': os.environ.get('GITHUB_REPOSITORY_OWNER', '').lower(), + 'simple': str(args.simple).lower(), 'dry_run': str(args.dry_run).lower(), + 'date': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'matrix': json.dumps({'include': targets(args.simple)}, separators=(',', ':'))} + with Path(os.environ['GITHUB_OUTPUT']).open('a') as output: + for key, value in result.items(): + output.write(f'{key}={value}\n') + + +def generate_config(args): + data = config(args.simple if args.mode == 'publish' else False) + data['snapshot'] = {'version_template': '{{ .Env.RELEASE_VERSION }}'} + data['dockers'] = [] + data['docker_manifests'] = [] + if args.mode == 'build': + target = {'goos': args.goos, 'goarch': args.goarch} + if target not in targets(): + raise ValueError('unsupported build target') + for build in data['builds']: + build['goos'], build['goarch'], build['ignore'] = [args.goos], [args.goarch], [] + build['ldflags'] = [re.sub(r'{{\s*\.Date\s*}}', '{{ .Env.RELEASE_DATE }}', flag) + for flag in build.get('ldflags', [])] + else: + # Artifacts are supplied through the OSS extra_files mechanism. No build + # is repeated on the publishing runner, and release templates stay intact. + data['before'] = {'hooks': []} + data['builds'] = [{'id': 'sub2api', 'skip': True}] + data['archives'] = [] + extra = [{'glob': 'release-input/sub2api_*.tar.gz'}, {'glob': 'release-input/sub2api_*.zip'}] + if args.simple: + data['checksum'] = {'disable': True} + else: + data['release']['extra_files'] = extra + data['checksum'] = {'name_template': 'checksums.txt', 'algorithm': 'sha256', 'extra_files': extra} + Path(args.output).write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True)) + + +def collect(args): + target = {'goos': args.goos, 'goarch': args.goarch} + name = archive_name(args.version, target) + source = Path('dist') / name + checksums = {line.split()[1].lstrip('*'): line.split()[0] for line in Path('dist/checksums.txt').read_text().splitlines()} + digest = sha256(source) + if checksums.get(name) != digest: + raise ValueError('archive does not match the build checksum') + output = Path(args.output) + output.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, output / name) + manifest = {'sha': args.sha, 'version': args.version, 'target': target, 'archive': name, 'sha256': digest} + (output / f"manifest-{args.goos}-{args.goarch}.json").write_text(json.dumps(manifest) + '\n') + + +def verify(args): + directory = Path(args.input) + expected = set() + for target in targets(args.simple): + name = archive_name(args.version, target) + manifest_name = f"manifest-{target['goos']}-{target['goarch']}.json" + expected.update((name, manifest_name)) + manifest = json.loads((directory / manifest_name).read_text()) + if manifest != {'sha': args.sha, 'version': args.version, 'target': target, + 'archive': name, 'sha256': sha256(directory / name)}: + raise ValueError(f'build provenance or checksum mismatch: {name}') + if {p.name for p in directory.iterdir()} != expected: + raise ValueError('missing or unexpected release artifacts') + + +def contexts(args): + verify(args) + for target in targets(args.simple): + if target['goos'] != 'linux': + continue + dest = Path(args.output) / target['goarch'] + dest.mkdir(parents=True, exist_ok=True) + with tarfile.open(Path(args.input) / archive_name(args.version, target), 'r:gz') as archive: + members = [member for member in archive.getmembers() if member.name in ('sub2api', './sub2api')] + if len(members) != 1 or not members[0].isfile(): + raise ValueError('archive must contain one regular sub2api binary') + with archive.extractfile(members[0]) as source, (dest / 'sub2api').open('wb') as output: + shutil.copyfileobj(source, output) + (dest / 'sub2api').chmod(0o755) + shutil.copy2('Dockerfile.goreleaser', dest / 'Dockerfile') + (dest / 'deploy').mkdir(exist_ok=True) + shutil.copy2('deploy/docker-entrypoint.sh', dest / 'deploy/docker-entrypoint.sh') + shutil.copytree('backend/resources', dest / 'backend/resources', dirs_exist_ok=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + p = commands.add_parser('plan') + p.add_argument('--ref', required=True) + p.add_argument('--simple', action='store_true') + p.add_argument('--dry-run', action='store_true') + p.set_defaults(run=plan) + p = commands.add_parser('config') + p.add_argument('mode', choices=['build', 'publish']) + p.add_argument('--goos') + p.add_argument('--goarch') + p.add_argument('--simple', action='store_true') + p.add_argument('--output', required=True) + p.set_defaults(run=generate_config) + p = commands.add_parser('collect') + for arg in ('version', 'sha', 'goos', 'goarch', 'output'): + p.add_argument('--' + arg, required=True) + p.set_defaults(run=collect) + for command, handler in [('verify', verify), ('contexts', contexts)]: + p = commands.add_parser(command) + for arg in ('version', 'sha', 'input'): + p.add_argument('--' + arg, required=True) + p.add_argument('--simple', action='store_true') + if command == 'contexts': + p.add_argument('--output', required=True) + p.set_defaults(run=handler) + args = parser.parse_args() + args.run(args) + + +if __name__ == '__main__': + main() diff --git a/.github/release-tools/requirements-release.txt b/.github/release-tools/requirements-release.txt new file mode 100644 index 0000000000..f62ce0c56d --- /dev/null +++ b/.github/release-tools/requirements-release.txt @@ -0,0 +1 @@ +PyYAML==6.0.3 diff --git a/.github/release-tools/test_release_matrix.py b/.github/release-tools/test_release_matrix.py new file mode 100644 index 0000000000..bbfc0fc673 --- /dev/null +++ b/.github/release-tools/test_release_matrix.py @@ -0,0 +1,191 @@ +import argparse +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('release_matrix', Path(__file__).with_name('release_matrix.py')) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + + +class ReleaseMatrixTest(unittest.TestCase): + def setUp(self): + self.previous = Path.cwd() + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + os.chdir(self.temp.name) + self.addCleanup(os.chdir, self.previous) + for name in ('.goreleaser.yaml', '.goreleaser.simple.yaml'): + shutil.copyfile(ROOT / name, name) + Path('backend/cmd/server').mkdir(parents=True) + release.VERSION_FILE.write_text('9.8.7\n') + + def fixture_artifacts(self, simple=False): + directory = Path('release-input') + directory.mkdir() + for target in release.targets(simple): + name = release.archive_name('9.8.7', target) + archive = directory / name + if target['goos'] == 'linux': + with tarfile.open(archive, 'w:gz') as out: + info = tarfile.TarInfo('sub2api') + info.size = 7 + info.mode = 0o755 + out.addfile(info, io.BytesIO(b'fixture')) + else: + archive.write_bytes(b'fixture archive') + metadata = {'version': '9.8.7', 'sha': 'a' * 40, 'target': target, + 'archive': name, 'sha256': release.sha256(archive)} + (directory / f"manifest-{target['goos']}-{target['goarch']}.json").write_text(json.dumps(metadata)) + return argparse.Namespace(input='release-input', version='9.8.7', sha='a' * 40, simple=simple, output='contexts') + + def test_full_and_simple_matrix_match_existing_targets(self): + full = release.targets() + self.assertEqual(len(full), 5) + self.assertNotIn({'goos': 'windows', 'goarch': 'arm64'}, full) + self.assertEqual(release.targets(True), [{'goos': 'linux', 'goarch': 'amd64'}]) + + def test_leaf_keeps_packaging_and_selects_only_one_target(self): + original = release.config() + release.generate_config(argparse.Namespace(mode='build', simple=False, goos='darwin', goarch='arm64', output='leaf.yaml')) + leaf = yaml.safe_load(Path('leaf.yaml').read_text()) + self.assertEqual(leaf['builds'][0]['goos'], ['darwin']) + self.assertEqual(leaf['builds'][0]['goarch'], ['arm64']) + self.assertEqual(leaf['builds'][0]['ignore'], []) + self.assertEqual(leaf['archives'], original['archives']) + self.assertEqual(leaf['release'], original['release']) + self.assertFalse(leaf['dockers']) + self.assertIn('{{ .Env.RELEASE_DATE }}', '\n'.join(leaf['builds'][0]['ldflags'])) + + def test_publication_config_has_no_compilation_or_docker_work(self): + for simple in (False, True): + with self.subTest(simple=simple): + original = release.config(simple) + release.generate_config(argparse.Namespace(mode='publish', simple=simple, output='publisher.yaml')) + data = yaml.safe_load(Path('publisher.yaml').read_text()) + self.assertTrue(data['builds'][0]['skip']) + self.assertFalse(data['archives']) + self.assertFalse(data['dockers']) + self.assertEqual(data['release']['header'], original['release']['header']) + self.assertEqual(data['release']['footer'], original['release']['footer']) + if simple: + self.assertTrue(data['checksum']['disable']) + self.assertTrue(data['release']['skip_upload']) + else: + self.assertEqual(data['checksum']['extra_files'], data['release']['extra_files']) + + def test_collect_and_verify_hash_and_source_binding(self): + args = self.fixture_artifacts() + release.verify(args) + file = next(Path(args.input).glob('*.tar.gz')) + file.write_bytes(b'corrupted') + with self.assertRaisesRegex(ValueError, 'checksum mismatch'): + release.verify(args) + + def test_missing_extra_and_wrong_commit_artifacts_are_rejected(self): + args = self.fixture_artifacts(True) + args.sha = 'b' * 40 + with self.assertRaises(ValueError): + release.verify(args) + args.sha = 'a' * 40 + Path('release-input/unexpected').write_text('not an asset') + with self.assertRaises(ValueError): + release.verify(args) + Path('release-input/unexpected').unlink() + next(Path('release-input').glob('*.tar.gz')).unlink() + with self.assertRaises(FileNotFoundError): + release.verify(args) + + def test_linux_context_preserves_binary_executable_mode(self): + args = self.fixture_artifacts() + Path('Dockerfile.goreleaser').write_text('FROM scratch\nCOPY sub2api /sub2api\n') + Path('deploy').mkdir() + Path('deploy/docker-entrypoint.sh').write_text('#!/bin/sh\nexec /app/sub2api\n') + Path('backend/resources').mkdir() + Path('backend/resources/data').write_text('fixture') + release.contexts(args) + for arch in ('amd64', 'arm64'): + binary = Path('contexts') / arch / 'sub2api' + self.assertEqual(binary.read_bytes(), b'fixture') + self.assertEqual(binary.stat().st_mode & 0o777, 0o755) + + def test_plan_requires_a_tag_for_publication(self): + args = argparse.Namespace(ref='main', dry_run=False, simple=False) + with patch.object(subprocess, 'check_output', return_value='a' * 40 + '\n'): + with self.assertRaisesRegex(ValueError, 'version tag'): + release.plan(args) + args.ref = 'v9.8.7' + with patch.object(subprocess, 'check_output', side_effect=['a' * 40 + '\n', 'b' * 40 + '\n']): + with self.assertRaisesRegex(ValueError, 'does not match'): + release.plan(args) + + def test_dry_run_plan_resolves_matrix_without_a_new_tag(self): + with patch.dict(os.environ, {'GITHUB_OUTPUT': 'outputs', 'GITHUB_REPOSITORY_OWNER': 'ExampleOwner'}), patch.object(subprocess, 'check_output', return_value='a' * 40 + '\n'): + release.plan(argparse.Namespace(ref='feature/matrix', dry_run=True, simple=False)) + output = dict(line.split('=', 1) for line in Path('outputs').read_text().splitlines()) + self.assertEqual(output['dry_run'], 'true') + self.assertEqual(output['owner_lower'], 'exampleowner') + self.assertEqual(len(json.loads(output['matrix'])['include']), 5) + + def test_docker_commands_do_not_publish_during_dry_run(self): + fake_bin = Path('bin') + fake_bin.mkdir() + docker = fake_bin / 'docker' + docker.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$DOCKER_LOG"\n') + docker.chmod(0o755) + env = {**os.environ, 'PATH': str(fake_bin.resolve()) + os.pathsep + os.environ['PATH'], + 'DOCKER_LOG': str(Path('docker.log').resolve()), 'RUNNER_TEMP': self.temp.name, + 'RELEASE_VERSION': '9.8.7', 'RELEASE_SHA': 'a' * 40, 'GITHUB_REPOSITORY': 'ExampleOwner/sub2api', + 'DRY_RUN': 'true', 'SIMPLE_RELEASE': 'false', 'DOCKERHUB_USERNAME': 'skip'} + subprocess.run(['bash', str(ROOT / '.github/release-tools/release-images.sh')], env=env, check=True) + log = Path('docker.log').read_text() + self.assertEqual(log.count('buildx build'), 2) + self.assertIn('linux/arm64', log) + self.assertNotIn('--push', log) + self.assertNotIn('imagetools', log) + self.assertNotIn('skip/sub2api', log) + self.assertIn('ghcr.io/exampleowner/sub2api', log) + + + def test_published_full_and_simple_image_tags(self): + fake_bin = Path('bin') + fake_bin.mkdir() + docker = fake_bin / 'docker' + docker.write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$DOCKER_LOG"\n') + docker.chmod(0o755) + for simple in (False, True): + with self.subTest(simple=simple): + log_path = Path(f'docker-{simple}.log').resolve() + env = {**os.environ, 'PATH': str(fake_bin.resolve()) + os.pathsep + os.environ['PATH'], + 'DOCKER_LOG': str(log_path), 'RUNNER_TEMP': self.temp.name, + 'RELEASE_VERSION': '9.8.7', 'RELEASE_SHA': 'a' * 40, 'GITHUB_REPOSITORY': 'ExampleOwner/sub2api', + 'DRY_RUN': 'false', 'SIMPLE_RELEASE': str(simple).lower(), 'DOCKERHUB_USERNAME': 'fixturehub'} + subprocess.run(['bash', str(ROOT / '.github/release-tools/release-images.sh')], env=env, check=True) + log = log_path.read_text() + self.assertIn('--push', log) + self.assertEqual(log.count('buildx build'), 1 if simple else 2) + if simple: + self.assertNotIn('fixturehub', log) + self.assertNotIn('imagetools', log) + self.assertIn('ghcr.io/exampleowner/sub2api:latest', log) + else: + self.assertEqual(log.count('imagetools create'), 2) + self.assertIn('fixturehub/sub2api:9.8', log) + self.assertIn('ghcr.io/exampleowner/sub2api:9', log) + + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 2bac7259a9..7aad1360b5 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -80,3 +80,14 @@ jobs: version: v2.13 args: --timeout=30m working-directory: backend + + release-helpers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: python -m pip install -r .github/release-tools/requirements-release.txt + - run: python -m unittest discover -s .github/release-tools -p 'test_release_matrix.py' + - run: bash -n .github/release-tools/release-images.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index abdfc9c81d..f14db67661 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: tag: - description: 'Tag to release (e.g., v1.0.0)' + description: 'Tag to release (or a branch/ref for dry run)' required: true type: string simple_release: @@ -15,179 +15,286 @@ on: required: false type: boolean default: false + dry_run: + description: 'Build and verify all selected artifacts without publishing' + required: false + type: boolean + default: false -# 环境变量:合并 workflow_dispatch 输入和 repository variable -# tag push 触发时读取 vars.SIMPLE_RELEASE,workflow_dispatch 时使用输入参数 -env: - SIMPLE_RELEASE: ${{ github.event.inputs.simple_release == 'true' || vars.SIMPLE_RELEASE == 'true' }} +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false permissions: - contents: write - packages: write + contents: read + +env: + SIMPLE_RELEASE: ${{ inputs.simple_release == true || vars.SIMPLE_RELEASE == 'true' }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }} jobs: - # Update VERSION file with tag version - update-version: + prepare: runs-on: ubuntu-latest + outputs: + sha: ${{ steps.plan.outputs.sha }} + owner_lower: ${{ steps.plan.outputs.owner_lower }} + tag: ${{ steps.plan.outputs.tag }} + version: ${{ steps.plan.outputs.version }} + date: ${{ steps.plan.outputs.date }} + matrix: ${{ steps.plan.outputs.matrix }} + simple: ${{ steps.plan.outputs.simple }} + dry_run: ${{ steps.plan.outputs.dry_run }} steps: - - name: Checkout + - name: Checkout workflow tooling uses: actions/checkout@v6 - - - name: Update VERSION file + with: + ref: ${{ github.sha }} + - name: Preserve tooling for releases of older tags + run: cp -R .github/release-tools "$RUNNER_TEMP/release-tools" + - name: Checkout selected application source + uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt" + - name: Resolve one source commit and target matrix + id: plan + env: + REQUESTED_REF: ${{ inputs.tag || github.ref_name }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION=${{ github.event.inputs.tag }} - VERSION=${VERSION#v} - else - VERSION=${GITHUB_REF#refs/tags/v} - fi - echo "$VERSION" > backend/cmd/server/VERSION - echo "Updated VERSION file to: $VERSION" - - - name: Upload VERSION artifact - uses: actions/upload-artifact@v7 + args=(--ref "$REQUESTED_REF") + if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi + if [[ "$DRY_RUN" == true ]]; then args+=(--dry-run); fi + python "$RUNNER_TEMP/release-tools/release_matrix.py" plan "${args[@]}" + - uses: actions/upload-artifact@v7 + with: + name: release-tooling + path: ${{ runner.temp }}/release-tools/ + retention-days: 1 + - uses: actions/upload-artifact@v7 with: name: version-file path: backend/cmd/server/VERSION retention-days: 1 build-frontend: + needs: prepare runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v6 + - uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.sha }} + - uses: pnpm/action-setup@v6 with: version: 9 - - - name: Setup Node.js - uses: actions/setup-node@v6 + - uses: actions/setup-node@v6 with: node-version: '20' - cache: 'pnpm' + cache: pnpm cache-dependency-path: frontend/pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install --frozen-lockfile + - run: pnpm install --frozen-lockfile working-directory: frontend - - - name: Build frontend - run: pnpm run build + - run: pnpm run build working-directory: frontend - - - name: Upload frontend artifact - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@v7 with: name: frontend-dist path: backend/internal/web/dist/ retention-days: 1 - release: - needs: [update-version, build-frontend] + build-binaries: + needs: [prepare, build-frontend] runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }} + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + RELEASE_DATE: ${{ needs.prepare.outputs.date }} steps: - - name: Checkout - uses: actions/checkout@v6 + - uses: actions/checkout@v6 with: + ref: ${{ needs.prepare.outputs.sha }} fetch-depth: 0 - ref: ${{ github.event.inputs.tag || github.ref }} - - - name: Download VERSION artifact - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@v8 + with: + name: release-tooling + path: ${{ runner.temp }}/release-tools/ + - uses: actions/download-artifact@v8 with: name: version-file path: backend/cmd/server/ - - - name: Download frontend artifact - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@v8 with: name: frontend-dist path: backend/internal/web/dist/ - - - name: Setup Go - uses: actions/setup-go@v6 + - uses: actions/setup-go@v6 with: go-version-file: backend/go.mod check-latest: false - cache-dependency-path: backend/go.sum - + cache: false - name: Verify Go version - run: | - go version | grep -q 'go1.27.0' + run: go version | grep -q 'go1.27.0' + - name: Restore target-specific Go cache + id: go-cache + uses: actions/cache/restore@v6 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('backend/go.mod', 'backend/go.sum') }}-${{ needs.prepare.outputs.sha }} + restore-keys: | + release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('backend/go.mod', 'backend/go.sum') }}- + release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}- + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt" + - name: Select one GoReleaser target + env: + TARGET_OS: ${{ matrix.goos }} + TARGET_ARCH: ${{ matrix.goarch }} + run: python "$RUNNER_TEMP/release-tools/release_matrix.py" config build --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output "$RUNNER_TEMP/goreleaser.matrix.yaml" + - name: Build and archive without publishing + uses: goreleaser/goreleaser-action@v7 + with: + version: '~> v2' + args: release --snapshot --clean --skip=validate,publish,docker,announce --config=${{ runner.temp }}/goreleaser.matrix.yaml + env: + GITHUB_REPO_OWNER: ${{ github.repository_owner }} + GITHUB_REPO_OWNER_LOWER: ${{ needs.prepare.outputs.owner_lower }} + GITHUB_REPO_NAME: ${{ github.event.repository.name }} + DOCKERHUB_USERNAME: skip + TAG_MESSAGE: '' + - name: Verify and collect the target archive + env: + TARGET_OS: ${{ matrix.goos }} + TARGET_ARCH: ${{ matrix.goarch }} + RELEASE_SHA: ${{ needs.prepare.outputs.sha }} + run: python "$RUNNER_TEMP/release-tools/release_matrix.py" collect --version "$RELEASE_VERSION" --sha "$RELEASE_SHA" --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output matrix-output + - uses: actions/upload-artifact@v7 + with: + name: release-binary-${{ matrix.goos }}-${{ matrix.goarch }} + path: matrix-output/ + if-no-files-found: error + retention-days: 1 + - name: Save updated target cache + if: ${{ steps.go-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v6 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ steps.go-cache.outputs.cache-primary-key }} - # Docker setup for GoReleaser + release: + needs: [prepare, build-binaries] + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + env: + SIMPLE_RELEASE: ${{ needs.prepare.outputs.simple }} + DRY_RUN: ${{ needs.prepare.outputs.dry_run }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + RELEASE_SHA: ${{ needs.prepare.outputs.sha }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.sha }} + fetch-depth: 0 + - uses: actions/download-artifact@v8 + with: + name: release-tooling + path: ${{ runner.temp }}/release-tools/ + - uses: actions/download-artifact@v8 + with: + pattern: release-binary-* + path: release-input + merge-multiple: true + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt" + - name: Verify complete matrix and prepare Docker contexts + run: | + args=(--version "$RELEASE_VERSION" --sha "$RELEASE_SHA" --input release-input --output .release-context) + if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi + python "$RUNNER_TEMP/release-tools/release_matrix.py" contexts "${args[@]}" + - uses: actions/setup-go@v6 + with: + go-version-file: backend/go.mod + cache: false - name: Set up QEMU uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - if: ${{ env.DOCKERHUB_USERNAME != '' }} + if: ${{ env.DRY_RUN != 'true' && env.SIMPLE_RELEASE != 'true' && env.DOCKERHUB_USERNAME != '' }} uses: docker/login-action@v3 env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Login to GitHub Container Registry + if: ${{ env.DRY_RUN != 'true' }} uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - - name: Fetch tags with annotations - run: | - # 确保获取完整的 annotated tag 信息 - git fetch --tags --force - - - name: Get tag message + - name: Build images and publish manifests + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME || 'skip' }} + run: bash "$RUNNER_TEMP/release-tools/release-images.sh" + - name: Get annotated tag notes id: tag_message run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - TAG_NAME=${{ github.event.inputs.tag }} - else - TAG_NAME=${GITHUB_REF#refs/tags/} - fi - echo "Processing tag: $TAG_NAME" - - # 获取完整的 tag message(跳过第一行标题) - TAG_MESSAGE=$(git tag -l --format='%(contents:body)' "$TAG_NAME") - - # 调试输出 - echo "Tag message length: ${#TAG_MESSAGE}" - echo "Tag message preview:" - echo "$TAG_MESSAGE" | head -10 - - # 使用 EOF 分隔符处理多行内容 - echo "message<> $GITHUB_OUTPUT - echo "$TAG_MESSAGE" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Set lowercase owner for GHCR - id: lowercase - run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - - - name: Run GoReleaser + TAG_MESSAGE=$(git tag -l --format='%(contents:body)' "$RELEASE_TAG") + DELIMITER="TAG_MESSAGE_$(openssl rand -hex 16)" + { + echo "message<<$DELIMITER" + echo "$TAG_MESSAGE" + echo "$DELIMITER" + } >> "$GITHUB_OUTPUT" + - name: Prepare publication-only GoReleaser config + run: | + args=(publish --output "$RUNNER_TEMP/goreleaser.publisher.yaml") + if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi + python "$RUNNER_TEMP/release-tools/release_matrix.py" config "${args[@]}" + - name: Publish existing archives and release notes uses: goreleaser/goreleaser-action@v7 with: version: '~> v2' - args: release --clean --skip=validate ${{ env.SIMPLE_RELEASE == 'true' && '--config=.goreleaser.simple.yaml' || '' }} + args: release --clean --skip=validate,before,docker ${{ env.DRY_RUN == 'true' && '--snapshot --skip=publish,announce' || '' }} --config=${{ runner.temp }}/goreleaser.publisher.yaml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_CURRENT_TAG: ${{ needs.prepare.outputs.tag }} TAG_MESSAGE: ${{ steps.tag_message.outputs.message }} GITHUB_REPO_OWNER: ${{ github.repository_owner }} - GITHUB_REPO_OWNER_LOWER: ${{ steps.lowercase.outputs.owner }} GITHUB_REPO_NAME: ${{ github.event.repository.name }} + GITHUB_REPO_OWNER_LOWER: ${{ needs.prepare.outputs.owner_lower }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME || 'skip' }} + - name: Upload dry-run checksums and metadata + if: ${{ env.DRY_RUN == 'true' }} + uses: actions/upload-artifact@v7 + with: + name: release-dry-run-report + path: | + dist/checksums.txt + dist/artifacts.json + dist/metadata.json + if-no-files-found: error + retention-days: 3 # Update DockerHub description - name: Update DockerHub description - if: ${{ env.SIMPLE_RELEASE != 'true' && env.DOCKERHUB_USERNAME != '' }} + if: ${{ env.DRY_RUN != 'true' && env.SIMPLE_RELEASE != 'true' && env.DOCKERHUB_USERNAME != '' }} uses: peter-evans/dockerhub-description@v5 env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} @@ -200,8 +307,9 @@ jobs: # Send Telegram notification - name: Send Telegram Notification - if: ${{ env.SIMPLE_RELEASE != 'true' }} + if: ${{ env.DRY_RUN != 'true' && env.SIMPLE_RELEASE != 'true' }} env: + TAG_MESSAGE: ${{ steps.tag_message.outputs.message }} TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} @@ -213,17 +321,12 @@ jobs: exit 0 fi - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - TAG_NAME=${{ github.event.inputs.tag }} - else - TAG_NAME=${GITHUB_REF#refs/tags/} - fi + TAG_NAME="$RELEASE_TAG" VERSION=${TAG_NAME#v} REPO="${{ github.repository }}" GHCR_IMAGE="ghcr.io/${REPO,,}" # ${,,} converts to lowercase # 获取 tag message 内容并转义 Markdown 特殊字符 - TAG_MESSAGE='${{ steps.tag_message.outputs.message }}' TAG_MESSAGE=$(echo "$TAG_MESSAGE" | sed 's/\([_*`\[]\)/\\\1/g') # 限制消息长度(Telegram 消息限制 4096 字符,预留空间给头尾固定内容) @@ -273,9 +376,13 @@ jobs: }')" sync-version-file: - needs: [release] - if: ${{ needs.release.result == 'success' }} + needs: [prepare, release] + if: ${{ needs.release.result == 'success' && needs.prepare.outputs.dry_run != 'true' }} runs-on: ubuntu-latest + permissions: + contents: write + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} steps: - name: Checkout default branch uses: actions/checkout@v6 @@ -284,12 +391,7 @@ jobs: - name: Sync VERSION file to released tag run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION=${{ github.event.inputs.tag }} - VERSION=${VERSION#v} - else - VERSION=${GITHUB_REF#refs/tags/v} - fi + VERSION="$RELEASE_VERSION" CURRENT_VERSION=$(tr -d '\r\n' < backend/cmd/server/VERSION || true) if [ "$CURRENT_VERSION" = "$VERSION" ]; then