diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000000..517b4ed944f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,382 @@ +name: Release + +on: + push: + tags: + - 'v*' + branches: + - 'feat/release-pipeline**' + workflow_dispatch: + inputs: + version_override: + description: 'Version override (leave empty to auto-detect from latest tag)' + type: string + default: '' + +env: + PRODUCTION_RELEASE_TAGS: '5.0,7,8' + DOCKER_REPO_ROLLING: owncloud/ocis-rolling + DOCKER_REPO_PRODUCTION: owncloud/ocis + GO_VERSION: '1.25.7' + NODE_VERSION: '24' + PNPM_VERSION: '10.11.0' + +jobs: + determine-release-type: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.info.outputs.version }} + is_production: ${{ steps.info.outputs.is_production }} + is_prerelease: ${{ steps.info.outputs.is_prerelease }} + docker_repos: ${{ steps.info.outputs.docker_repos }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ github.ref_type == 'branch' || (github.event_name == 'workflow_dispatch' && inputs.version_override == '') }} + with: + fetch-depth: 0 + fetch-tags: true + + - id: info + run: | + next_dev() { + local tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + local ver="${tag#v}"; IFS='.' read -r M m p <<< "${ver%%-*}" + echo "${M}.${m}.$((p + 1))-dev.1" + } + + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + VERSION="${{ inputs.version_override }}" + [[ -z "$VERSION" ]] && VERSION=$(next_dev) + elif [[ "${{ github.ref_type }}" == "branch" ]]; then + VERSION=$(next_dev) + else + VERSION="${GITHUB_REF#refs/tags/v}" + fi + + IS_PRODUCTION=false + for TAG in ${PRODUCTION_RELEASE_TAGS//,/ }; do + [[ "$VERSION" == "$TAG"* ]] && IS_PRODUCTION=true && break + done + + [[ "$VERSION" == *"-"* ]] && IS_PRERELEASE=true || IS_PRERELEASE=false + + if [[ "$IS_PRODUCTION" == "true" && "$IS_PRERELEASE" == "false" ]]; then + REPOS=[\"$DOCKER_REPO_ROLLING\",\"$DOCKER_REPO_PRODUCTION\"] + else + REPOS=[\"$DOCKER_REPO_ROLLING\"] + fi + + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "is_production=$IS_PRODUCTION" >> $GITHUB_OUTPUT + echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT + echo "docker_repos=$REPOS" >> $GITHUB_OUTPUT + + generate-code: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version: ${{ env.GO_VERSION }} + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ env.NODE_VERSION }} + - run: npm install --silent -g yarn npx --force + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4 + with: + version: ${{ env.PNPM_VERSION }} + - run: pnpm config set store-dir ./.pnpm-store && make ci-node-generate + env: + CHROMEDRIVER_SKIP_DOWNLOAD: 'true' + - run: make ci-go-generate + env: + BUF_TOKEN: ${{ secrets.BUF_API_TOKEN }} + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: generated-code + path: | + . + !.git + retention-days: 1 + + docker-build: + name: docker-build (${{ matrix.arch }}, ${{ matrix.repo }}) + runs-on: ${{ matrix.arch == 'amd64' && 'ubuntu-24.04' || 'ubuntu-24.04-arm' }} + needs: [determine-release-type, generate-code] + outputs: + digest-amd64-rolling: ${{ steps.digest.outputs.digest-amd64-rolling }} + digest-arm64-rolling: ${{ steps.digest.outputs.digest-arm64-rolling }} + digest-amd64: ${{ steps.digest.outputs.digest-amd64 }} + digest-arm64: ${{ steps.digest.outputs.digest-arm64 }} + strategy: + matrix: + arch: [amd64, arm64] + repo: ${{ fromJSON(needs.determine-release-type.outputs.docker_repos) }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: generated-code + path: . + - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version: ${{ env.GO_VERSION }} + - run: sudo apt-get update -q && sudo apt-get install -qy libvips libvips-dev + - run: make -C ocis release-linux-docker-${{ matrix.arch }} + env: + CGO_ENABLED: 1 + GOOS: linux + ENABLE_VIPS: true + - id: tags + run: | + VERSION="${{ needs.determine-release-type.outputs.version }}" + REPO="${{ matrix.repo }}" + ARCH="${{ matrix.arch }}" + MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1-2) + MAJOR=$(echo "$VERSION" | cut -d. -f1) + printf "tags<> $GITHUB_OUTPUT + - id: build + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + with: + context: ocis + file: ocis/docker/Dockerfile.linux.${{ matrix.arch }} + platforms: linux/${{ matrix.arch }} + push: true + provenance: false + build-args: | + REVISION=${{ github.sha }} + VERSION=${{ needs.determine-release-type.outputs.version }} + tags: ${{ steps.tags.outputs.tags }} + - id: digest + run: | + [[ "${{ matrix.repo }}" == *"rolling"* ]] \ + && echo "digest-${{ matrix.arch }}-rolling=${{ steps.build.outputs.digest }}" >> $GITHUB_OUTPUT \ + || echo "digest-${{ matrix.arch }}=${{ steps.build.outputs.digest }}" >> $GITHUB_OUTPUT + + docker-scan: + name: docker-scan (${{ matrix.arch }}, ${{ matrix.repo }}) + runs-on: ubuntu-latest + needs: [determine-release-type, docker-build] + strategy: + matrix: + arch: [amd64] + repo: ${{ fromJSON(needs.determine-release-type.outputs.docker_repos) }} + steps: + - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: ${{ matrix.repo }}:${{ needs.determine-release-type.outputs.version }}-linux-${{ matrix.arch }} + format: table + exit-code: 0 + severity: CRITICAL,HIGH + + docker-manifest: + name: docker-manifest (${{ matrix.repo }}) + runs-on: ubuntu-latest + needs: [determine-release-type, docker-build] + strategy: + matrix: + repo: ${{ fromJSON(needs.determine-release-type.outputs.docker_repos) }} + steps: + - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - if: ${{ contains(matrix.repo, 'rolling') }} + run: | + docker buildx imagetools create \ + -t "${{ matrix.repo }}:${{ needs.determine-release-type.outputs.version }}" \ + "${{ needs.docker-build.outputs.digest-amd64-rolling }}" \ + "${{ needs.docker-build.outputs.digest-arm64-rolling }}" + - if: ${{ !contains(matrix.repo, 'rolling') }} + run: | + docker buildx imagetools create \ + -t "${{ matrix.repo }}:${{ needs.determine-release-type.outputs.version }}" \ + "${{ needs.docker-build.outputs.digest-amd64 }}" \ + "${{ needs.docker-build.outputs.digest-arm64 }}" + + docker-readme: + name: docker-readme (${{ matrix.repo }}) + runs-on: ubuntu-latest + needs: [determine-release-type, docker-manifest] + strategy: + matrix: + repo: ${{ fromJSON(needs.determine-release-type.outputs.docker_repos) }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + repository: ${{ matrix.repo }} + readme-filepath: ocis/docker/README.md + + build-binaries: + name: build-binaries (${{ matrix.os }}) + runs-on: ubuntu-latest + needs: [determine-release-type, generate-code] + strategy: + matrix: + os: [linux, darwin] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: generated-code + path: . + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version: ${{ env.GO_VERSION }} + - run: | + make -C ocis release-${{ matrix.os }} OUTPUT=${{ needs.determine-release-type.outputs.version }} + make -C ocis release-finish + if [[ "${{ matrix.os }}" == "linux" ]]; then + cp assets/End-User-License-Agreement-for-ownCloud-Infinite-Scale.pdf ocis/dist/release/ + fi + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: binaries-${{ matrix.os }} + path: ocis/dist/release/* + retention-days: 1 + + security-scan-trivy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + scan-type: fs + format: table + exit-code: 0 + severity: CRITICAL,HIGH + + license-check: + runs-on: ubuntu-latest + needs: [determine-release-type, generate-code] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: generated-code + path: . + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ env.NODE_VERSION }} + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version: ${{ env.GO_VERSION }} + - run: npm install --silent -g yarn npx "pnpm@$PNPM_VERSION" --force + - run: make ci-node-check-licenses && make ci-node-save-licenses + - run: make ci-go-check-licenses && make ci-go-save-licenses + - run: tar -czf third-party-licenses.tar.gz -C third-party-licenses . + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: third-party-licenses + path: third-party-licenses.tar.gz + retention-days: 1 + + generate-changelog: + runs-on: ubuntu-latest + needs: [determine-release-type] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version: ${{ env.GO_VERSION }} + - run: make changelog CHANGELOG_VERSION=$(echo "${{ needs.determine-release-type.outputs.version }}" | cut -d'-' -f1) + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: changelog + path: ocis/dist/CHANGELOG.md + retention-days: 1 + + create-github-release: + runs-on: ubuntu-latest + needs: [determine-release-type, build-binaries, license-check, generate-changelog] + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: binaries-linux + path: release-assets + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: binaries-darwin + path: release-assets + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: third-party-licenses + path: release-assets + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: changelog + path: . + - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + with: + tag_name: ${{ needs.determine-release-type.outputs.version }} + name: ${{ needs.determine-release-type.outputs.version }} + body_path: CHANGELOG.md + prerelease: ${{ needs.determine-release-type.outputs.is_prerelease == 'true' }} + files: release-assets/* + + audit-release: + runs-on: ubuntu-latest + needs: [determine-release-type, create-github-release] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: binaries-linux + path: release-assets + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: binaries-darwin + path: release-assets + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: third-party-licenses + path: release-assets + - run: | + python3 scripts/audit-release.py \ + --version "${{ needs.determine-release-type.outputs.version }}" \ + --dir release-assets/ \ + --github-release --docker + + notify: + runs-on: ubuntu-latest + if: always() + needs: + - determine-release-type + - generate-code + - docker-build + - docker-scan + - docker-manifest + - docker-readme + - build-binaries + - security-scan-trivy + - license-check + - generate-changelog + - create-github-release + - audit-release + steps: + - run: | + [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]] \ + && STATUS="FAILURE" || STATUS="SUCCESS" + [[ "${{ github.event_name }}" == "schedule" ]] \ + && SOURCE="nightly-${{ github.ref_name }}" \ + || SOURCE="${{ github.ref_type == 'tag' && format('tag {0}', github.ref_name) || github.ref_name }}" + SHA="${{ github.sha }}" + MSG="${STATUS} [${{ github.repository }}#${SHA:0:8}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) (${SOURCE}) by **${{ github.triggering_actor }}**" + curl -sf -X PUT \ + -H "Authorization: Bearer ${{ secrets.MATRIX_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d "{\"msgtype\":\"m.text\",\"body\":\"${MSG}\"}" \ + "${{ secrets.MATRIX_HOMESERVER }}/_matrix/client/r0/rooms/${{ secrets.MATRIX_ROOMID }}/send/m.room.message/$(date +%s)" diff --git a/scripts/audit-release.py b/scripts/audit-release.py new file mode 100755 index 00000000000..a83f264da4e --- /dev/null +++ b/scripts/audit-release.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import json +import os +import struct +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +BINARY_PLATFORMS = ["darwin-amd64", "darwin-arm64", "linux-386", "linux-amd64", "linux-arm", "linux-arm64"] +MACHO_MAGIC = b"\xcf\xfa\xed\xfe" +ELF_MAGIC = b"\x7fELF" +BINARY_REF = { + "linux-amd64": (ELF_MAGIC, 64, 0x3e), + "linux-arm64": (ELF_MAGIC, 64, 0xb7), + "linux-arm": (ELF_MAGIC, 32, 0x28), + "linux-386": (ELF_MAGIC, 32, 0x03), + "darwin-amd64": (MACHO_MAGIC, 64, 0x01000007), + "darwin-arm64": (MACHO_MAGIC, 64, 0x0100000c), +} +EULA = "End-User-License-Agreement-for-ownCloud-Infinite-Scale.pdf" +LICENSES = "third-party-licenses.tar.gz" +PROD_TAGS = ("5.0", "7", "8") + +passed = failed = 0 + +def ok(msg): + global passed; passed += 1; print(f"[PASS] {msg}") + +def fail(msg): + global failed; failed += 1; print(f"[FAIL] {msg}", file=sys.stderr) + +def expected_files(v): + return [f for p in BINARY_PLATFORMS for f in (f"ocis-{v}-{p}", f"ocis-{v}-{p}.sha256")] + [EULA, LICENSES] + +def is_production(v): + return any(v.startswith(t) for t in PROD_TAGS) + +def run(cmd): + return subprocess.run(cmd, capture_output=True, text=True) + +def gh_api(path, token): + req = urllib.request.Request( + f"https://api.github.com{path}", + headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}, + ) + with urllib.request.urlopen(req) as r: + return json.load(r) + + +def check_local(directory, version): + expected = expected_files(version) + present = {f.name for f in directory.iterdir()} + missing = [f for f in expected if f not in present] + extra = present - set(expected) + if missing: fail(f"file set: missing {missing}") + else: ok(f"file set: {len(expected)} files present") + if extra: fail(f"file set: unexpected {extra}") + + for platform in BINARY_PLATFORMS: + bin_path = directory / f"ocis-{version}-{platform}" + sha_path = directory / f"ocis-{version}-{platform}.sha256" + + if bin_path.exists(): + magic, bits, machine = BINARY_REF[platform] + if bin_path.stat().st_size == 0: + fail(f"{bin_path.name}: empty file") + else: + h = bin_path.read_bytes()[:20] + if not h.startswith(magic): + fail(f"{bin_path.name}: wrong magic {h[:4].hex()}") + elif magic == ELF_MAGIC: + cls, mach = h[4], struct.unpack_from("