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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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 # Builds and pushes the multi-arch app image (by digest) to Docker Hub + # GHCR; the manifest job then creates the named tags. Only runs when # semantic-release produced a version (or fell back to the latest tag). if: needs.release.outputs.new_version 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: v${{ needs.release.outputs.new_version }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to Docker Hub uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GitHub Container Registry uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GHCR_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | snapotter/snapotter ghcr.io/snapotter-hq/snapotter - name: Build and push by digest id: build uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: docker/Dockerfile platforms: ${{ matrix.platform }} build-args: | SNAPOTTER_ANALYTICS=on SNAPOTTER_POSTHOG_PROJECT_ID=${{ secrets.SNAPOTTER_POSTHOG_KEY }} SNAPOTTER_SENTRY_DSN=${{ secrets.SNAPOTTER_SENTRY_DSN }} SNAPOTTER_SENTRY_DSN_WEB=${{ secrets.SNAPOTTER_SENTRY_DSN_WEB }} SENTRY_RELEASE=${{ needs.release.outputs.new_version }} secrets: | sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }} 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: write 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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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: Checkout for trivyignore uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: sparse-checkout: .trivyignore sparse-checkout-cone-mode: false - 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: "1" ignore-unfixed: true severity: "CRITICAL,HIGH" trivyignores: ".trivyignore" - 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@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v3 if: always() with: sarif_file: "trivy-results.sarif" - name: Run Trivy (JSON report) if: always() uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: "ghcr.io/snapotter-hq/snapotter@sha256:${{ steps.digest.outputs.sha }}" format: "json" output: "snapotter-v${{ needs.release.outputs.new_version }}-trivy.json" ignore-unfixed: true - name: Upload Trivy report to GitHub Release if: always() env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.release.outputs.new_version }} run: | gh release upload "v${VERSION}" \ "snapotter-v${VERSION}-trivy.json" \ --clobber --repo snapotter-hq/SnapOtter sbom: name: Generate SBOM needs: [release, docker] runs-on: ubuntu-latest permissions: contents: write packages: read 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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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: Install Syft uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - name: Generate SBOMs env: IMAGE: "ghcr.io/snapotter-hq/snapotter@sha256:${{ steps.digest.outputs.sha }}" VERSION: ${{ needs.release.outputs.new_version }} run: | syft scan "$IMAGE" -o "cyclonedx-json=snapotter-v${VERSION}-sbom.cdx.json" syft scan "$IMAGE" -o "spdx-json=snapotter-v${VERSION}-sbom.spdx.json" - name: Upload SBOMs to GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.release.outputs.new_version }} run: | gh release upload "v${VERSION}" \ "snapotter-v${VERSION}-sbom.cdx.json" \ "snapotter-v${VERSION}-sbom.spdx.json" \ --clobber --repo snapotter-hq/SnapOtter ai-bundles: name: AI Bundles needs: [release, docker] # DISABLED for 2.0.0: all v2.0.0 bundles are already built, verified, and # published to HuggingFace (deepsafe/feature-bundles/v2.0.0). Re-enable once # the ordering is fixed so it runs after manifest creates the :VERSION tag. if: false # The top-level `permissions: {}` default means this reusable-workflow call # grants no token scopes by default. ai-bundles.yml's jobs declare # `contents: read` / `packages: read`, and GitHub rejects a called workflow # requesting scopes the caller never granted -- failing at startup before any # job runs. Grant them here so the call passes startup validation. permissions: contents: read packages: read uses: ./.github/workflows/ai-bundles.yml with: version: ${{ needs.release.outputs.new_version }} secrets: inherit manifest: name: Create Multi-Arch Manifests needs: [release, docker, scan] runs-on: ubuntu-latest # Manual publish gate: this job creates the public `latest` / version tags on # Docker Hub + GHCR (the point at which images become pullable by tag). The # `publish-images` environment requires a maintainer to approve the run in the # Actions UI before it proceeds, so a release never auto-publishes to the # registries. The image is already built and Trivy-scanned by this point. environment: publish-images 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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GitHub Container Registry uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GHCR_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.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 ' *)