Skip to content

Public release: 2026-05-27 #14

Public release: 2026-05-27

Public release: 2026-05-27 #14

Workflow file for this run

# Release the observer Go binary as 6 npm packages whenever a v* tag
# is pushed.
#
# Flow:
# 0. frontend job — npm ci + npm run build inside web/.
# Uploads web/dist as an artifact so the 5
# platform binaries embed the SAME bundle
# (build-once, ship-everywhere). Prior to
# this job the workflow trusted whatever
# `internal/intelligence/dashboard/webapp/
# dist/` had been committed — a dev who
# tagged without running `make web-build`
# shipped a stale React bundle.
# 1. build job (matrix x5) — cross-compile each binary against the
# fresh dist; linux variants additionally
# cross-compile `antigravity-bridge.exe`
# (Windows amd64) into the same bin/ dir
# so npm-installed observer-on-WSL2 finds
# the bridge next to the observer binary
# (locateBridgeBinary's exe-dir lookup).
# Uploads the whole bin/ folder per
# platform.
# 2. publish job — pulls every platform's bin/ folder,
# drops it into npm/observer-<plat>/bin/,
# stamps the version into every
# package.json, npm-publishes the 5
# platform packages, then publishes the
# main shim last so its
# optionalDependencies resolve.
# 3. release job — creates a GitHub Release on the private
# repo using the matching CHANGELOG.md
# section as the body. Useful as a
# maintainer-side changelog browser.
# 4. public_release job — creates a GitHub Release on the PUBLIC
# repo (marmutapp/superbased-observer)
# with per-platform binary archives +
# SHA256SUMS attached. Runs in parallel
# with publish (only needs build), so
# direct downloads land independent of
# npm publish success. Waits for the v*
# tag to exist on the public repo —
# scripts/release.sh public <version>
# pushes it as part of the maintainer's
# `release.sh full <version>` invocation.
#
# Auth:
# - NPM_TOKEN_OBSERVER npm token scoped to @superbased/observer*.
# See https://docs.npmjs.com/about-access-tokens.
# - PUBLIC_REPO_TOKEN fine-grained PAT with `contents: write` on
# marmutapp/superbased-observer (the public repo). Used by the
# public_release job to create the GH Release with binary assets.
# Must be set BEFORE the first tag that uses this flow lands —
# missing-token failures surface as a 404 from `gh release create`.
name: npm-release
on:
push:
tags:
- 'v*'
permissions:
contents: write # GH Release step needs write to create the release.
jobs:
# ----------------------------------------------------------------
# Build the React frontend once, upload as artifact. All 5 binary
# jobs download the same artifact so they embed identical bundles.
# ----------------------------------------------------------------
frontend:
name: build frontend
# Gate the entire pipeline to the private repo. The orphan tree
# pushed to the public repo includes .github/workflows/ (because
# PRIVATE_ONLY_PATHS in scripts/release.sh doesn't list it — and
# there's value in publishing the workflow source for transparency),
# so the v* tag push to public:main also fires this workflow on
# the public repo. The public repo has no NPM_TOKEN_OBSERVER (and
# shouldn't — npm publish belongs to the maintainer), so the
# publish job would error with ENEEDAUTH. Beyond auth, the whole
# cross-compile + publish + release pipeline is wasted work on the
# public side (the private run already produces every artifact).
# Gating frontend skips it on the public repo; every other job
# chains via `needs: frontend` transitively, so they all skip too.
if: github.repository == 'marmutapp/superbased-observer-private'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install + build webapp
run: |
cd web
npm ci
npm run build
ls -l dist/
# Sanity: index.html must reference the freshly-hashed
# bundle. If Vite produced an empty dist (silent error)
# we want to fail loudly rather than embed an empty FS.
test -f dist/index.html
grep -qE 'assets/index-[A-Za-z0-9_-]+\.js' dist/index.html
- name: Upload web/dist
uses: actions/upload-artifact@v5
with:
name: web-dist
path: web/dist/
retention-days: 7
if-no-files-found: error
# ----------------------------------------------------------------
# Build the 5 platform binaries in parallel. Each binary embeds the
# web-dist artifact built in the frontend job above.
# ----------------------------------------------------------------
build:
name: build ${{ matrix.target }}
needs: frontend
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
include:
- target: linux-x64
goos: linux
goarch: amd64
ext: ''
include_bridge: true
- target: linux-arm64
goos: linux
goarch: arm64
ext: ''
include_bridge: true
- target: darwin-x64
goos: darwin
goarch: amd64
ext: ''
include_bridge: false
- target: darwin-arm64
goos: darwin
goarch: arm64
ext: ''
include_bridge: false
- target: win32-x64
goos: windows
goarch: amd64
ext: '.exe'
include_bridge: false
steps:
- uses: actions/checkout@v5
- name: Download web/dist
uses: actions/download-artifact@v6
with:
name: web-dist
path: web/dist/
- name: Sync embed dir from web/dist
# The Go binary embeds internal/intelligence/dashboard/webapp/
# dist/ via //go:embed; web/dist is the build output. Mirror
# the layout `make web-build` produces locally.
run: |
set -euo pipefail
rm -rf internal/intelligence/dashboard/webapp/dist
mkdir -p internal/intelligence/dashboard/webapp/dist
cp -R web/dist/. internal/intelligence/dashboard/webapp/dist/
ls -l internal/intelligence/dashboard/webapp/dist/
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache: true
- name: Cross-compile observer
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: '0' # pure-Go thanks to modernc.org/sqlite
run: |
# Strip the leading 'v' from the tag (v1.2.3 -> 1.2.3) so the
# binary's `observer --version` matches the npm package version.
VERSION="${GITHUB_REF_NAME#v}"
mkdir -p npm/observer-${{ matrix.target }}/bin
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
-o npm/observer-${{ matrix.target }}/bin/observer${{ matrix.ext }} \
./cmd/observer
# Make the unix binary executable. Windows .exe is fine as-is.
if [ -z "${{ matrix.ext }}" ]; then
chmod +x npm/observer-${{ matrix.target }}/bin/observer
fi
ls -l npm/observer-${{ matrix.target }}/bin/
# Sanity-check the embedded version inline on the linux-x64
# build (the runner OS) — fail the job if it doesn't match.
if [ "${{ matrix.target }}" = "linux-x64" ]; then
EMBEDDED=$(./npm/observer-linux-x64/bin/observer --version 2>&1 | head -1 | awk '{print $NF}')
if [ "$EMBEDDED" != "$VERSION" ]; then
echo "::error::version mismatch: --version reports '$EMBEDDED' want '$VERSION'"
exit 1
fi
echo "embedded version: $EMBEDDED ✓"
fi
- name: Cross-compile observer-org
# The org server (cmd/observer-org) ships separately from the
# agent: its own per-platform binary (Teams M5), uploaded under
# a distinct artifact name so the public_release + provenance
# jobs can pick it up without disturbing the observer bin/ tree.
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: '0' # pure-Go thanks to modernc.org/sqlite
run: |
set -euo pipefail
# Same version stamp as observer — cmd/observer-org has a
# `var version` stamped via -X main.version=.
VERSION="${GITHUB_REF_NAME#v}"
mkdir -p orgbin/observer-org-${{ matrix.target }}
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
-o orgbin/observer-org-${{ matrix.target }}/observer-org${{ matrix.ext }} \
./cmd/observer-org
if [ -z "${{ matrix.ext }}" ]; then
chmod +x orgbin/observer-org-${{ matrix.target }}/observer-org
fi
ls -l orgbin/observer-org-${{ matrix.target }}/
- name: Cross-compile antigravity-bridge.exe
# WSL2-on-Windows linux installs need the Windows-side helper
# binary that runs under powershell.exe to bridge the WSL→
# Windows-localhost network gap when calling Antigravity's
# local language_server gRPC API. Pre-CI this binary was a
# `make build` afterthought on the maintainer's box; shipping
# it inside the linux-* npm packages means WSL2 users no
# longer have to clone + build to use the Antigravity adapter.
# Always Windows amd64 — observer's locateBridgeBinary looks
# for it next to the observer executable.
if: matrix.include_bridge
env:
GOOS: windows
GOARCH: amd64
CGO_ENABLED: '0'
run: |
go build -trimpath -ldflags="-s -w" \
-o npm/observer-${{ matrix.target }}/bin/antigravity-bridge.exe \
./cmd/antigravity-bridge
ls -l npm/observer-${{ matrix.target }}/bin/
- name: Upload bin/ folder
# Upload the whole bin/ dir (not just observer) so the linux
# variants' antigravity-bridge.exe rides along. The publish
# job re-hydrates by copying the entire artifact tree back
# into npm/observer-<plat>/bin/.
uses: actions/upload-artifact@v5
with:
name: observer-${{ matrix.target }}
path: npm/observer-${{ matrix.target }}/bin/
retention-days: 7
if-no-files-found: error
- name: Upload observer-org binary
# Separate artifact (observer-org-<plat>) so the org server
# binary never lands in the npm packages — only the public
# release archives + SLSA provenance subjects consume it.
uses: actions/upload-artifact@v5
with:
name: observer-org-${{ matrix.target }}
path: orgbin/observer-org-${{ matrix.target }}/
retention-days: 7
if-no-files-found: error
# ----------------------------------------------------------------
# SBOMs (Teams M5). Generate a CycloneDX SBOM per binary and upload
# them as a single `sboms` artifact for attachment to the public
# release. Only the linux-x64 binaries are catalogued: syft reads
# the Go module table embedded in the binary, which is identical
# across GOOS/GOARCH targets, so one platform's SBOM describes the
# dependency graph for all of them.
# ----------------------------------------------------------------
sbom:
name: SBOMs
needs: build
runs-on: ubuntu-latest
steps:
- name: Download linux-x64 observer binary
uses: actions/download-artifact@v6
with:
name: observer-linux-x64
path: sbom-in/observer
- name: Download linux-x64 observer-org binary
uses: actions/download-artifact@v6
with:
name: observer-org-linux-x64
path: sbom-in/observer-org
- name: Install syft
uses: anchore/sbom-action/download-syft@v0
- name: Generate CycloneDX SBOMs
run: |
set -euo pipefail
mkdir -p sboms
syft "sbom-in/observer/observer" -o cyclonedx-json > sboms/observer.cdx.json
syft "sbom-in/observer-org/observer-org" -o cyclonedx-json > sboms/observer-org.cdx.json
ls -l sboms/
- name: Upload SBOMs
uses: actions/upload-artifact@v5
with:
name: sboms
path: sboms/
retention-days: 7
if-no-files-found: error
# ----------------------------------------------------------------
# Docker image (Teams M5). Build the observer-org server image from
# Dockerfile.observer-org, push it to ghcr.io under the marmutapp
# org using the private repo's GITHUB_TOKEN, then keyless-sign it
# with cosign BY DIGEST (immutable reference; signing a mutable tag
# is a TOCTOU footgun). The image digest is exposed as a job output
# so downstream steps / docs can reference the exact pushed image.
# ----------------------------------------------------------------
docker_image:
name: Docker image (observer-org)
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v5
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.observer-org
push: true
tags: ghcr.io/marmutapp/observer-org:${{ github.ref_name }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Sign image by digest (keyless)
# Keyless OIDC signing — no long-lived keys; the signature is
# bound to this workflow's identity. Verifiable later with
# `cosign verify ... --certificate-identity-regexp ...`.
run: |
cosign sign --yes "ghcr.io/marmutapp/observer-org@${{ steps.push.outputs.digest }}"
# ----------------------------------------------------------------
# Hashes (Teams M5). Compute the base64-encoded sha256sum subject
# lines over the observer + observer-org per-platform binaries. This
# feeds the SLSA generator's `base64-subjects` input. Kept in a
# small dedicated job so the build matrix stays untouched.
# ----------------------------------------------------------------
hashes:
name: Compute SLSA subjects
needs: build
runs-on: ubuntu-latest
outputs:
hashes: ${{ steps.hash.outputs.hashes }}
steps:
- name: Download all binaries
uses: actions/download-artifact@v6
with:
path: artifacts
- name: Compute base64-subjects
id: hash
run: |
set -euo pipefail
# sha256sum over every observer + observer-org per-platform
# binary, then base64-encode the combined sums file as the
# SLSA generator expects (one "<sha256> <name>" line each).
(
cd artifacts
sha256sum \
observer-*/observer observer-*/observer.exe \
observer-org-*/observer-org observer-org-*/observer-org.exe \
2>/dev/null || true
) > sums.txt
# Fail loudly if nothing matched (artifact layout changed).
if [ ! -s sums.txt ]; then
echo "::error::no binaries matched for SLSA subjects"
exit 1
fi
echo "=== subjects ==="
cat sums.txt
echo "hashes=$(base64 -w0 sums.txt)" >> "$GITHUB_OUTPUT"
# ----------------------------------------------------------------
# SLSA Level 3 provenance (Teams M5). The official reusable generator
# produces a signed in-toto attestation over the binary subjects.
#
# The build runs on the PRIVATE repo, so by default the generator
# halts — it refuses to record a private repo's name in the public
# Rekor transparency log. `private-repository: true` overrides that.
# The builder identity (…/superbased-observer-private) is already
# disclosed in this release's cosign-verify instructions, so the Rekor
# entry exposes nothing new. (This was the v1.7.0 release failure: the
# generator's `final` job exited 27 because the generate step halted on
# the private-repo guard — no provenance was produced.)
#
# `upload-assets: false`: the generator attaches assets to the release
# on the repo it runs on (private), but consumers get the PUBLIC
# release. So we keep the provenance as a workflow artifact and the
# public_release job below copies the .intoto.jsonl onto the PUBLIC
# GitHub Release, next to the binaries it attests. Verifiable with
# slsa-verifier --source-uri github.com/marmutapp/superbased-observer-private.
# ----------------------------------------------------------------
provenance:
needs: [build, hashes]
permissions:
actions: read # read the workflow run for provenance metadata
id-token: write # keyless signing of the attestation
contents: write # required by the reusable generator's API
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
with:
base64-subjects: "${{ needs.hashes.outputs.hashes }}"
# Produce the provenance as a workflow artifact; public_release
# attaches it to the PUBLIC release (where the binaries live).
upload-assets: false
# The build runs on the private repo — override the generator's
# private-repo Rekor guard (builder identity already public via cosign).
private-repository: true
# ----------------------------------------------------------------
# Publish all 6 packages from a single job after every binary is
# built. Sequential publish so a failure mid-flight is recoverable
# (idempotent — npm rejects re-publishing the same version, so just
# re-run from where it stopped).
# ----------------------------------------------------------------
publish:
name: publish to npm
needs: build
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
# Node 22 is the active LTS; v20 emits a deprecation warning
# on every workflow run. The publish job only invokes `npm
# publish` so it's Node-version-insensitive — bumping clears
# the warning at zero risk.
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Resolve version
id: version
run: |
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Download all platform bin/ folders
uses: actions/download-artifact@v6
with:
path: artifacts
- name: Place binaries in their package directories
run: |
set -euo pipefail
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do
mkdir -p "npm/observer-${target}/bin"
# The artifact contains the whole bin/ tree — observer +
# (for linux variants) antigravity-bridge.exe. cp -R
# preserves both.
cp -R "artifacts/observer-${target}/." "npm/observer-${target}/bin/"
# Make unix binaries executable.
if [ "${target}" != "win32-x64" ]; then
chmod +x "npm/observer-${target}/bin/observer"
fi
done
# Sanity print before publish so the workflow log shows
# exactly which binaries were packaged.
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do
echo "=== npm/observer-${target}/bin/ ==="
ls -l "npm/observer-${target}/bin/"
done
- name: Stamp release version into every package.json
run: ./scripts/sync-npm-version.sh "${GITHUB_REF_NAME}"
- name: Publish platform packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN_OBSERVER }}
run: |
set -euo pipefail
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do
(cd "npm/observer-${target}" && npm publish --access public)
done
- name: Publish main package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN_OBSERVER }}
run: |
# Main goes last so its optionalDependencies resolve cleanly
# for any user installing the moment after this step finishes.
cd npm/observer && npm publish --access public
- name: Summary
run: |
{
echo "## npm-release ${GITHUB_REF_NAME} published"
echo ""
echo "Six packages updated:"
echo "- \`@superbased/observer@${GITHUB_REF_NAME#v}\`"
for t in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64; do
echo "- \`@superbased/observer-${t}@${GITHUB_REF_NAME#v}\`"
done
echo ""
echo "linux-x64 + linux-arm64 packages include \`antigravity-bridge.exe\` for WSL2 users."
echo ""
echo "Smoke test:"
echo '```bash'
echo "npm install -g @superbased/observer@${GITHUB_REF_NAME#v}"
echo "observer --version"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# ----------------------------------------------------------------
# GitHub Release on the private repo. The body is the CHANGELOG
# section matching the tag — pulls everything between
# "## [<version>] —" and the next "## [" so the release page
# mirrors what landed in CHANGELOG.md. Public repo is orphan +
# force-pushed so its Releases tab isn't a fit; this is for the
# maintainer-side changelog browser.
# ----------------------------------------------------------------
release:
name: GitHub Release
needs: publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Extract CHANGELOG section
id: changelog
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
# awk pulls everything between the matching version header
# and the next "## [" header (exclusive). Header form is
# "## [1.2.3] — 2026-05-17".
BODY=$(awk -v v="$VERSION" '
/^## \[/ {
if (in_section) exit
if ($0 ~ "^## \\[" v "\\]") { in_section = 1; next }
}
in_section { print }
' CHANGELOG.md)
if [ -z "$BODY" ]; then
echo "::warning::CHANGELOG.md has no section for v${VERSION}; release body will be empty"
BODY="(no changelog entry for v${VERSION} — check CHANGELOG.md)"
fi
# GH Actions multiline output via heredoc:
{
echo "body<<CHANGELOG_EOF"
echo "$BODY"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
body: ${{ steps.changelog.outputs.body }}
draft: false
prerelease: false
# ----------------------------------------------------------------
# Public repo GitHub Release with per-platform binary archives.
# Runs in parallel with publish (only needs build) so direct
# downloaders aren't blocked on npm-publish quirks. Waits for the
# public tag to exist — scripts/release.sh public <version> pushes
# it ~30s after this workflow's trigger.
# ----------------------------------------------------------------
public_release:
name: Public Release (binaries)
# Also needs `sbom` so the observer.cdx.json / observer-org.cdx.json
# artifacts exist when this job downloads everything into artifacts/,
# and `provenance` so the SLSA .intoto.jsonl artifact is present to
# attach to the public release (the release advertises SLSA L3, so a
# missing attestation must fail the job — see the staging step below).
needs: [build, sbom, provenance]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Download all platform bin/ folders
uses: actions/download-artifact@v6
with:
path: artifacts
- name: Wait for v* tag on public repo
run: |
# scripts/release.sh public <version> force-pushes the same
# tag to the public remote right after the private push that
# triggered this workflow. The build job typically takes
# 2–3 min; the public tag push is sub-30s, so under normal
# `release.sh full` flows the tag is already there. The
# poll is for the edge case where the public push lags
# (e.g. operator ran `release.sh tag` then `release.sh
# public` separately with a delay between).
#
# Uses unauthenticated curl rather than `gh api` because the
# public repo's ref endpoint is readable anonymously — and
# because the workflow's gh CLI inherits empty GH_TOKEN when
# PUBLIC_REPO_TOKEN isn't set yet (first-time setup), which
# makes gh fail with 401 instead of falling back to public
# access. The original gh-based wait silenced 401s through
# `--silent 2>/dev/null` and looked indistinguishable from
# "tag not yet pushed" — the v1.6.19 first-time release hit
# exactly this misdiagnosis.
set -euo pipefail
URL="https://api.github.com/repos/marmutapp/superbased-observer/git/ref/tags/${GITHUB_REF_NAME}"
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "$URL" || echo "000")
case "$STATUS" in
200)
echo "Public tag ${GITHUB_REF_NAME} exists ✓"
exit 0
;;
404)
echo "[$i/12] Waiting 10s for public tag ${GITHUB_REF_NAME}... (404)"
;;
403)
# Rate-limited (anonymous quota: 60 req/hr/IP). Sleep
# the recommended backoff and keep trying — at 12
# attempts × 10s this is highly unlikely.
echo "[$i/12] Rate-limited (HTTP 403); backing off 10s"
;;
*)
# Network blip, DNS hiccup, or unexpected upstream
# response. Surface but keep polling.
echo "[$i/12] Unexpected HTTP $STATUS from $URL; retrying"
;;
esac
sleep 10
done
echo "::error::Public tag ${GITHUB_REF_NAME} not present after 120s. Run \`scripts/release.sh public ${GITHUB_REF_NAME}\` locally, then re-run this job."
exit 1
- name: Sanity-check PUBLIC_REPO_TOKEN is set
env:
PUBLIC_REPO_TOKEN: ${{ secrets.PUBLIC_REPO_TOKEN }}
run: |
# Fail fast with a clear message when the PAT isn't set.
# Without this, the `Create / update public GitHub Release`
# step below would error with `gh: HTTP 404` (cross-repo
# write rejected as a 404 to avoid leaking repo existence),
# which is harder to diagnose than "secret not set".
set -euo pipefail
if [ -z "${PUBLIC_REPO_TOKEN:-}" ]; then
echo "::error::PUBLIC_REPO_TOKEN secret is not set on this repo. See docs/release-runbook.md '#### Required CI secret: PUBLIC_REPO_TOKEN' for setup. Until set, this job fails fast; the rest of the npm-release pipeline (npm publish, private GH Release) is unaffected."
exit 1
fi
echo "PUBLIC_REPO_TOKEN is set ✓"
- name: Build per-platform archives + SHA256SUMS
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME}"
mkdir -p release-assets
# Linux + Darwin: tar.gz preserves the executable bit
# (chmod +x set in the build job rides along). Windows:
# zip is the conventional Windows artifact format.
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do
tar -czf "release-assets/observer-${VERSION}-${target}.tar.gz" \
-C "artifacts/observer-${target}" .
done
(cd "artifacts/observer-win32-x64" && \
zip -r "$GITHUB_WORKSPACE/release-assets/observer-${VERSION}-win32-x64.zip" .)
# Org server (observer-org) per-platform archives (Teams M5),
# built the same way from the observer-org-<plat> artifacts.
for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do
tar -czf "release-assets/observer-org-${VERSION}-${target}.tar.gz" \
-C "artifacts/observer-org-${target}" .
done
(cd "artifacts/observer-org-win32-x64" && \
zip -r "$GITHUB_WORKSPACE/release-assets/observer-org-${VERSION}-win32-x64.zip" .)
# SBOMs (Teams M5) ride along to the release; copy them next
# to the archives so the upload globs and SHA256SUMS pick
# them up.
cp artifacts/sboms/observer.cdx.json release-assets/
cp artifacts/sboms/observer-org.cdx.json release-assets/
# SHA256SUMS — verifiable via `shasum -a 256 -c SHA256SUMS`
# or `sha256sum -c SHA256SUMS` from inside the downloads dir.
(cd release-assets && sha256sum *.tar.gz *.zip *.cdx.json > SHA256SUMS)
echo "=== release-assets/ ==="
ls -lh release-assets/
echo "=== SHA256SUMS ==="
cat release-assets/SHA256SUMS
- name: Stage SLSA provenance for the public release
run: |
# The provenance job runs with upload-assets:false, so the
# signed .intoto.jsonl arrives as a workflow artifact and was
# pulled into artifacts/ by the download step above. Copy it
# next to the binaries it attests. The release notes advertise
# SLSA L3, so a missing attestation is a hard failure rather
# than a silent ship-without-provenance (the v1.7.0 footgun).
# Deliberately NOT added to SHA256SUMS — provenance attests the
# binaries; it isn't itself one of the checksummed artifacts.
set -euo pipefail
# -type f is load-bearing: download-artifact extracts each
# artifact into a DIRECTORY named after it (artifacts/
# multiple.intoto.jsonl/), with the real file inside. Without
# -type f, find matches the directory first and the cp below
# fails with "omitting directory" (the v1.7.1 footgun).
prov=$(find artifacts -type f -name '*.intoto.jsonl' | head -1)
if [ -z "$prov" ]; then
echo "::error::No *.intoto.jsonl provenance artifact found under artifacts/. The provenance job must succeed with upload-assets:false (artifact) before this job runs."
exit 1
fi
cp "$prov" "release-assets/$(basename "$prov")"
echo "Staged provenance: $(basename "$prov")"
- name: Compose release body
id: changelog
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
# Identical extraction logic to the private 'release' job.
BODY=$(awk -v v="$VERSION" '
/^## \[/ {
if (in_section) exit
if ($0 ~ "^## \\[" v "\\]") { in_section = 1; next }
}
in_section { print }
' CHANGELOG.md)
if [ -z "$BODY" ]; then
BODY="(no changelog entry for v${VERSION})"
fi
# Append a Downloads section so visitors know what's
# attached + how to verify + that npm is the alternative.
{
echo "$BODY"
echo ""
echo "---"
echo ""
echo "### Downloads"
echo ""
echo "Pre-built binaries for each supported platform are attached below. Linux variants bundle \`antigravity-bridge.exe\` next to the observer binary for WSL2 users of the Antigravity adapter."
echo ""
echo "| Platform | Asset |"
echo "|---|---|"
echo "| Linux x86_64 | \`observer-${GITHUB_REF_NAME}-linux-x64.tar.gz\` |"
echo "| Linux arm64 | \`observer-${GITHUB_REF_NAME}-linux-arm64.tar.gz\` |"
echo "| macOS x86_64 (Intel) | \`observer-${GITHUB_REF_NAME}-darwin-x64.tar.gz\` |"
echo "| macOS arm64 (Apple Silicon) | \`observer-${GITHUB_REF_NAME}-darwin-arm64.tar.gz\` |"
echo "| Windows x86_64 | \`observer-${GITHUB_REF_NAME}-win32-x64.zip\` |"
echo ""
echo "Verify with \`sha256sum -c SHA256SUMS\` (or \`shasum -a 256 -c SHA256SUMS\` on macOS) from the directory containing the downloads."
echo ""
echo "Also available via npm: \`npm install -g @superbased/observer@${VERSION}\`"
echo ""
echo "### Org server (Docker)"
echo ""
echo "The self-hosted org server ships as a Docker image and as per-platform \`observer-org-${GITHUB_REF_NAME}-*\` archives (attached below)."
echo ""
echo '```bash'
echo "docker pull ghcr.io/marmutapp/observer-org:${GITHUB_REF_NAME}"
echo '```'
echo ""
echo "The image is keyless-signed with cosign. Verify it:"
echo ""
echo '```bash'
echo "cosign verify ghcr.io/marmutapp/observer-org:${GITHUB_REF_NAME} \\"
echo " --certificate-identity-regexp 'https://github.com/marmutapp/superbased-observer-private/.*' \\"
echo " --certificate-oidc-issuer https://token.actions.githubusercontent.com"
echo '```'
echo ""
echo "### Supply chain"
echo ""
echo "CycloneDX SBOMs are attached: \`observer.cdx.json\` and \`observer-org.cdx.json\`."
echo ""
echo "SLSA Level 3 build provenance for the binaries is attached below as a \`*.intoto.jsonl\` attestation. The build runs on the private origin repo, so pass that as the source when verifying an extracted binary with [slsa-verifier](https://github.com/slsa-framework/slsa-verifier):"
echo ""
echo '```bash'
echo "slsa-verifier verify-artifact ./observer \\"
echo " --provenance-path *.intoto.jsonl \\"
echo " --source-uri github.com/marmutapp/superbased-observer-private"
echo '```'
} > release-body.md
echo "Wrote release-body.md ($(wc -l < release-body.md) lines)"
- name: Create / update public GitHub Release
env:
GH_TOKEN: ${{ secrets.PUBLIC_REPO_TOKEN }}
run: |
set -euo pipefail
# Use --clobber-via-recreate semantics: if a release for this
# tag already exists (e.g. a prior CI run was retried), edit
# the existing release rather than failing with "already
# exists". `gh release create --notes-file ...` errors on
# existing tag; switch to view-or-create.
if gh release view "${GITHUB_REF_NAME}" --repo marmutapp/superbased-observer >/dev/null 2>&1; then
echo "Release ${GITHUB_REF_NAME} already exists on public repo — updating body + re-uploading assets"
gh release edit "${GITHUB_REF_NAME}" \
--repo marmutapp/superbased-observer \
--notes-file release-body.md
gh release upload "${GITHUB_REF_NAME}" \
--repo marmutapp/superbased-observer \
--clobber \
release-assets/*.tar.gz \
release-assets/*.zip \
release-assets/*.cdx.json \
release-assets/*.intoto.jsonl \
release-assets/SHA256SUMS
else
gh release create "${GITHUB_REF_NAME}" \
--repo marmutapp/superbased-observer \
--title "${GITHUB_REF_NAME}" \
--notes-file release-body.md \
release-assets/*.tar.gz \
release-assets/*.zip \
release-assets/*.cdx.json \
release-assets/*.intoto.jsonl \
release-assets/SHA256SUMS
fi
- name: Summary
run: |
{
echo "## Public release ${GITHUB_REF_NAME} published"
echo ""
echo "[Release page](https://github.com/marmutapp/superbased-observer/releases/tag/${GITHUB_REF_NAME})"
echo ""
echo "Assets attached:"
echo '```'
ls -lh release-assets/
echo '```'
} >> "$GITHUB_STEP_SUMMARY"