name: Release on: workflow_dispatch: permissions: {} jobs: release: name: Semantic Release runs-on: ubuntu-latest permissions: contents: write issues: write pull-requests: write outputs: new_version: ${{ steps.check.outputs.version }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 persist-credentials: false - uses: ./.github/actions/setup - name: Save release notes id: notes run: | if [ -f .release-notes.md ]; then cp .release-notes.md /tmp/release-notes.md echo "has_notes=true" >> "$GITHUB_OUTPUT" echo "Custom release notes found -- will apply after release." else echo "No .release-notes.md found -- using default release notes." fi - name: Run semantic-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npx semantic-release - name: Check for new release id: check run: | if [ -f .release-version ]; then echo "version=$(cat .release-version)" >> "$GITHUB_OUTPUT" else # semantic-release found no new commits — tag already exists from a # previous run. Fall back to the latest git tag so the Docker build # jobs still run (useful when re-triggering after a push failure). latest=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//') if [ -n "$latest" ]; then echo "version=$latest" >> "$GITHUB_OUTPUT" echo "Re-using existing tag v${latest} for Docker build." else echo "::error::semantic-release did not produce a new version. No releasable commits found." exit 1 fi fi - name: Update GitHub release notes if: steps.notes.outputs.has_notes == 'true' && steps.check.outputs.version env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | echo "Updating release v${{ steps.check.outputs.version }} with custom notes..." gh release edit "v${{ steps.check.outputs.version }}" \ --notes-file /tmp/release-notes.md echo "Release notes updated successfully." - name: Update docs changelog if: steps.notes.outputs.has_notes == 'true' && steps.check.outputs.version env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.check.outputs.version }} run: | CHANGELOG="apps/docs/changelog.md" if [ ! -f "$CHANGELOG" ]; then echo "No docs changelog found, skipping." exit 0 fi NOTES="/tmp/release-notes.md" # Build the new entry: ## vX.Y.Z header + release notes body (skip the first ## Highlights/Upgrade sections wrapper) { echo "" echo "## v${VERSION}" echo "" # Strip the ## Highlights header and ## Upgrade section, keep the rest sed '1{/^## Highlights$/d}' "$NOTES" | sed '/^## Upgrade$/,/^---$/d' | sed '/^---$/d' echo "" echo "[Full diff on GitHub](https://github.com/snapotter-hq/SnapOtter/compare/v$(git tag --sort=-v:refname | grep -E '^v[0-9]' | sed -n '2p' | sed 's/^v//')...v${VERSION})" echo "" echo "---" echo "" } > /tmp/changelog-entry.md # Insert after the "# Changelog" header sed -i '/^# Changelog$/r /tmp/changelog-entry.md' "$CHANGELOG" # Commit and push git config user.name "SnapOtter" git config user.email "snapotter.hq@gmail.com" git add "$CHANGELOG" git commit -m "docs: update changelog for v${VERSION}" || true git push origin HEAD:main || true echo "Docs changelog updated for v${VERSION}." prebuilt: name: Archive (${{ matrix.arch }}) needs: release if: needs.release.outputs.new_version permissions: contents: write strategy: fail-fast: false matrix: include: - runner: ubuntu-latest arch: amd64 - runner: ubuntu-24.04-arm arch: arm64 runs-on: ${{ matrix.runner }} steps: - name: Checkout release tag uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: v${{ needs.release.outputs.new_version }} - uses: ./.github/actions/setup - name: Build web frontend run: pnpm --filter @snapotter/web build - name: Prune to production dependencies run: | rm -rf node_modules apps/*/node_modules packages/*/node_modules npm pkg delete scripts.prepare pnpm install --prod --frozen-lockfile - name: Create archive env: VERSION: ${{ needs.release.outputs.new_version }} ARCH: ${{ matrix.arch }} run: | rm -rf apps/web/src apps/web/public apps/web/index.html apps/web/tsconfig.json rm -rf apps/landing apps/docs apps/demo rm -rf tests .husky scripts rm -rf .releaserc.json biome.json .editorconfig .gitattributes rm -f CHANGELOG.md README.md CONTRIBUTING.md SECURITY.md ARCHIVE_NAME="snapotter-v${VERSION}-linux-${ARCH}.tar.gz" cd .. mv SnapOtter snapotter tar czf "/tmp/${ARCHIVE_NAME}" --exclude='.git' --exclude='.github' --exclude='.gitignore' snapotter/ mv snapotter SnapOtter cd SnapOtter echo "archive_name=${ARCHIVE_NAME}" >> "$GITHUB_ENV" echo "Archive: ${ARCHIVE_NAME} ($(du -sh /tmp/${ARCHIVE_NAME} | cut -f1))" - name: Generate checksum run: cd /tmp && sha256sum "${archive_name}" > "${archive_name}.sha256" - name: Upload to GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.release.outputs.new_version }} run: gh release upload "v${VERSION}" "/tmp/${archive_name}" "/tmp/${archive_name}.sha256" --clobber --repo snapotter-hq/SnapOtter docker: name: Build (${{ matrix.platform }}) needs: release permissions: contents: read packages: write strategy: fail-fast: false matrix: include: - platform: linux/amd64 runner: ubuntu-latest - platform: linux/arm64 runner: ubuntu-24.04-arm runs-on: ${{ matrix.runner }} steps: - name: Free disk space run: | sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ /usr/local/share/boost /opt/hostedtoolcache/CodeQL sudo docker system prune -af df -h / - name: Prepare run: | platform=${{ matrix.platform }} echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Checkout release tag uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: v${{ needs.release.outputs.new_version }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Log in to Docker Hub uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GitHub Container Registry uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GHCR_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: | snapotter/snapotter ghcr.io/snapotter-hq/snapotter - name: Build and push by digest id: build uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: docker/Dockerfile platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} outputs: type=image,"name=snapotter/snapotter,ghcr.io/snapotter-hq/snapotter",push-by-digest=true,name-canonical=true,push=true cache-from: type=registry,ref=ghcr.io/snapotter-hq/snapotter:cache-${{ env.PLATFORM_PAIR }} cache-to: type=registry,ref=ghcr.io/snapotter-hq/snapotter:cache-${{ env.PLATFORM_PAIR }},mode=max - name: Export digest run: | mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - name: Upload digest uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-${{ env.PLATFORM_PAIR }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 scan: name: Trivy Container Scan needs: [release, docker] runs-on: ubuntu-latest permissions: contents: read packages: read security-events: write steps: - name: Download amd64 digest uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: digests-linux-amd64 path: /tmp/digests - name: Log in to GitHub Container Registry uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GHCR_TOKEN }} - name: Get digest id: digest run: | sha=$(ls /tmp/digests | head -1) echo "sha=$sha" >> "$GITHUB_OUTPUT" - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: "ghcr.io/snapotter-hq/snapotter@sha256:${{ steps.digest.outputs.sha }}" format: "table" exit-code: "0" ignore-unfixed: true severity: "CRITICAL,HIGH" - name: Upload results to GitHub Security if: always() uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: "ghcr.io/snapotter-hq/snapotter@sha256:${{ steps.digest.outputs.sha }}" format: "sarif" output: "trivy-results.sarif" ignore-unfixed: true severity: "CRITICAL,HIGH" - name: Upload SARIF uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v3 if: always() with: sarif_file: "trivy-results.sarif" manifest: name: Create Multi-Arch Manifests needs: [release, docker] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: /tmp/digests pattern: digests-* merge-multiple: true - name: Log in to Docker Hub uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GitHub Container Registry uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GHCR_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: | snapotter/snapotter ghcr.io/snapotter-hq/snapotter tags: | type=semver,pattern={{version}},value=v${{ needs.release.outputs.new_version }} type=semver,pattern={{major}}.{{minor}},value=v${{ needs.release.outputs.new_version }} type=semver,pattern={{major}},value=v${{ needs.release.outputs.new_version }} type=raw,value=latest - name: Create Docker Hub manifest working-directory: /tmp/digests run: | docker buildx imagetools create \ $(jq -cr '.tags | map(select(startswith("snapotter/")) | "-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf 'snapotter/snapotter@sha256:%s ' *) - name: Create GHCR manifest working-directory: /tmp/digests run: | docker buildx imagetools create \ $(jq -cr '.tags | map(select(startswith("ghcr.io/")) | "-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf 'ghcr.io/snapotter-hq/snapotter@sha256:%s ' *)