Files
SnapOtter/.github/workflows/release.yml
T
SnapOtterandGitHub 9819c5885e feat(analytics): inject Sentry DSN + PostHog key from build env (#367)
* fix(analytics): bake real Sentry DSN and lower trace sampling

The bake script emitted a placeholder Sentry DSN even in on mode, so every
published image initialized Sentry against a dead endpoint and no events ever
reached the project. Point it at the real snapotter project DSN.

Also drop tracesSampleRate from 1 to 0.1. It governs only performance
transactions (errors are always captured), so 100% fleet-wide tracing would
drain Sentry quota for no benefit.

* fix(analytics): point baked Sentry DSN at the snapotter org

* refactor(analytics): inject Sentry DSN + PostHog key from build env

#336 replaced the analytics creds with placeholders but never added a way to
put real values back at build time, so any image built from the repo since then
ships dead analytics (the live fleet only still reports because publishing is
paused and it runs a pre-placeholder image).

Restore the pipeline the clean way: bake-analytics.mjs reads SNAPOTTER_SENTRY_DSN
and SNAPOTTER_POSTHOG_KEY from the environment; the official image's CI supplies
them from repo secrets via build args. A build with neither stays disabled, so
building from source never phones home. Both values are public (they ship in the
browser bundle), so this is about not making source builds report, not secrecy.

Supersedes the hardcoded DSN: real creds are no longer committed to the repo.
2026-06-29 10:41:58 +08:00

461 lines
17 KiB
YAML

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
# PUBLISHING PAUSED (2026-06-17): building and pushing the app image to
# Docker Hub + GHCR is disabled while the app is still being stabilized.
# The scan, sbom, ai-bundles, and manifest jobs all `need` this job, so
# they are skipped too and nothing reaches either registry. The release,
# changelog, and source-archive (prebuilt) jobs still run.
# To resume publishing, delete the `if: ${{ false }}` line below.
if: ${{ false }}
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@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 }}
build-args: |
SNAPOTTER_ANALYTICS=on
SNAPOTTER_POSTHOG_KEY=${{ secrets.SNAPOTTER_POSTHOG_KEY }}
SNAPOTTER_SENTRY_DSN=${{ secrets.SNAPOTTER_SENTRY_DSN }}
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@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: 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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # 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@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: 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]
if: needs.release.outputs.new_version
# 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
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 ' *)