Files
plainleaf/.github/workflows/ci.yml
T

455 lines
15 KiB
YAML

name: CI
on:
push:
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
# Never cancel an in-flight tag (release) run; cancel superseded branch runs.
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
ARCHITECTURES: linux/arm64,linux/amd64,linux/arm/v7
IMAGE_NAME: silverbullet
WEBSITE_IMAGE: zefhemel/silverbullet-website
NAMESPACE_GITHUB: silverbulletmd
NAMESPACE_DOCKER: zefhemel
permissions:
contents: read
jobs:
# Single source of truth for branch/channel decisions.
config:
runs-on: ubuntu-latest
outputs:
publish: ${{ steps.cfg.outputs.publish }}
is_edge: ${{ steps.cfg.outputs.is_edge }}
is_tag: ${{ steps.cfg.outputs.is_tag }}
gh_release_tag: ${{ steps.cfg.outputs.gh_release_tag }}
gh_release_name: ${{ steps.cfg.outputs.gh_release_name }}
docker_edge_tag: ${{ steps.cfg.outputs.docker_edge_tag }}
docker_runtime_api_tag: ${{ steps.cfg.outputs.docker_runtime_api_tag }}
website_tag: ${{ steps.cfg.outputs.website_tag }}
steps:
- id: cfg
env:
EVENT: ${{ github.event_name }}
REF: ${{ github.ref }}
run: |
set -euo pipefail
# Channel configuration. Pushes to PUBLISH_BRANCH and manual dispatches
# publish the edge channel; git tags publish stable releases.
# The website image intentionally diverges from the app image's
# versioned channel — its edge channel is the plain :edge tag (not
# :v2), with :latest reserved for tagged releases.
PUBLISH_BRANCH="main"
GH_RELEASE_TAG="edge"
GH_RELEASE_NAME="Edge"
DOCKER_EDGE_TAG="v2"
DOCKER_RUNTIME_API_TAG="v2-runtime-api"
WEBSITE_TAG="edge"
is_tag=false
is_edge=false
publish=false
if [[ "$REF" == refs/tags/* ]]; then
is_tag=true
publish=true
elif [[ "$REF" == "refs/heads/$PUBLISH_BRANCH" ]]; then
is_edge=true
publish=true
elif [[ "$EVENT" == "workflow_dispatch" ]]; then
# Manual dispatch builds + publishes the edge channel.
is_edge=true
publish=true
fi
{
echo "publish=$publish"
echo "is_edge=$is_edge"
echo "is_tag=$is_tag"
echo "gh_release_tag=$GH_RELEASE_TAG"
echo "gh_release_name=$GH_RELEASE_NAME"
echo "docker_edge_tag=$DOCKER_EDGE_TAG"
echo "docker_runtime_api_tag=$DOCKER_RUNTIME_API_TAG"
echo "website_tag=$WEBSITE_TAG"
} >> "$GITHUB_OUTPUT"
echo "publish=$publish is_edge=$is_edge is_tag=$is_tag"
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Setup repo
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- name: Install dependencies
run: npm ci
- name: Run build
run: npm run build
- name: Run checks
run: |
npm run check
npm test
# Core's Rust suite, including the headless-Chrome runtime e2e tests. The
# client bundle has to exist on disk first: rust-embed serves from disk in
# debug builds, so without it the server has no client to hand the headless
# page.
test-rust:
runs-on: ubuntu-latest
steps:
- name: Setup repo
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: Swatinem/rust-cache@v2
with:
key: test-rust
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install dependencies
run: npm ci
- name: Build client bundle
run: npm run build
- name: Install Playwright browser (chromium)
run: npx playwright install --with-deps chromium
# The runtime tests skip themselves when no browser is found, which would
# silently gut this job. Point them (and the servers they spawn) at
# Playwright's chromium explicitly: their skip guard reads CHROMIUM_PATH
# with the same precedence the server does, and refuses to skip at all
# when CI is set — so this job can no longer pass having exercised nothing.
- name: Resolve chromium path
run: |
echo "CHROMIUM_PATH=$(node -e "console.log(require('@playwright/test').chromium.executablePath())")" >> "$GITHUB_ENV"
- name: Run Rust tests
run: cargo test --workspace --all-features
test-e2e:
runs-on: ubuntu-latest
steps:
- name: Setup repo
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: Swatinem/rust-cache@v2
with:
key: e2e-debug
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install dependencies
run: npm ci
# Debug server (serves the client bundle from disk) — far faster to compile
# than the release build. The embedded-bundle path is covered by the
# separate test-e2e-release job below.
- name: Build (debug server + client bundle)
run: make build-e2e
- name: Install Playwright browser (chromium)
run: npx playwright install --with-deps chromium
- name: Run e2e tests
run: npx playwright test --project=chromium
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: test-results/
retention-days: 7
# Validates the shipped release binary's rust-embed embedded bundle. Kept
# separate from test-e2e so the fast gate isn't blocked on a release compile;
# runs in parallel and gates release/docker.
test-e2e-release:
runs-on: ubuntu-latest
steps:
- name: Setup repo
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: Swatinem/rust-cache@v2
with:
key: e2e-release
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install dependencies
run: npm ci
- name: Build (release server with embedded bundle)
run: make build-rs
- name: Install Playwright browser (chromium)
run: npx playwright install --with-deps chromium
- name: Run embedded-bundle e2e tests
run: npx playwright test --project=release
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-release
path: test-results/
retention-days: 7
# Compilation doesn't depend on the test suites, so the build runs in parallel
# with them instead of after — the test gate is enforced on the *publishing*
# jobs (release/docker) below, which keeps "nothing ships if tests fail".
build:
needs: [config]
if: needs.config.outputs.publish == 'true'
uses: ./.github/workflows/_build.yml
secrets: inherit
release:
needs: [config, build, test-frontend, test-rust, test-e2e, test-e2e-release]
if: needs.config.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write # npm provenance / JSR auth on tag publishes
steps:
- name: Setup repo
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
registry-url: "https://registry.npmjs.org"
- name: Install npm dependencies
run: npm ci
- name: Build plug-compile bundle
run: npm run build:plug-compile
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
path: dist-artifacts
pattern: release-zips-*
merge-multiple: true
# Branch / manual dispatch → refresh the rolling edge prerelease.
- name: Publish edge prerelease
if: needs.config.outputs.is_edge == 'true'
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
draft: false
tag_name: ${{ needs.config.outputs.gh_release_tag }}
name: ${{ needs.config.outputs.gh_release_name }}
body: Automated build from commit ${{ github.sha }}
prerelease: true
files: |
docs/CHANGELOG.md
dist/plug-compile.js
dist-artifacts/silverbullet-server-*.zip
dist-artifacts/sb-*.zip
# Tag push → publish that tag's (non-pre) release. Tag is inferred from ref.
- name: Publish tagged release
if: needs.config.outputs.is_tag == 'true'
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
draft: false
files: |
docs/CHANGELOG.md
dist/plug-compile.js
dist-artifacts/silverbullet-server-*.zip
dist-artifacts/sb-*.zip
# npm publish ONLY on tag pushes.
- name: Build npm package
if: needs.config.outputs.is_tag == 'true'
run: npm run build
- name: Publish to npm
if: needs.config.outputs.is_tag == 'true'
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
docker:
needs: [config, build, test-frontend, test-rust, test-e2e, test-e2e-release]
if: needs.config.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Setup repo
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Download raw docker binaries
uses: actions/download-artifact@v4
with:
name: docker-binaries
path: .
- name: Verify binaries are static (no interpreter)
run: |
set -euo pipefail
for b in silverbullet-amd64 silverbullet-arm64 silverbullet-arm; do
echo "== $b =="; file "$b"
if file "$b" | grep -q "interpreter"; then
echo "ERROR: $b is dynamically linked (needs a loader); expected static musl"; exit 1
fi
done
- name: Set up QEMU for multi-arch builds with buildx
uses: docker/setup-qemu-action@v3
with:
platforms: ${{ env.ARCHITECTURES }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: ${{ env.ARCHITECTURES }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the ghcr Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.NAMESPACE_DOCKER }}/${{ env.IMAGE_NAME }}
ghcr.io/${{ env.NAMESPACE_GITHUB }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{raw}},enable=true
type=semver,pattern=latest,enable=true
type=raw,value=${{ needs.config.outputs.docker_edge_tag }},enable=${{ needs.config.outputs.is_edge == 'true' }}
- name: Build and push main docker images
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ env.ARCHITECTURES }}
push: true
file: Dockerfile
provenance: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: "Runtime API: Extract metadata"
id: rc_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.NAMESPACE_DOCKER }}/${{ env.IMAGE_NAME }}
ghcr.io/${{ env.NAMESPACE_GITHUB }}/${{ env.IMAGE_NAME }}
# latest=false: the default (latest=auto) appends a bare `latest` tag
# for any matching type=semver entry, which would point `latest` at
# the fat runtime-api image (it is pushed after the main one).
flavor: latest=false
tags: |
type=semver,pattern={{raw}}-runtime-api,enable=true
type=semver,pattern=latest-runtime-api,enable=true
type=raw,value=${{ needs.config.outputs.docker_runtime_api_tag }},enable=${{ needs.config.outputs.is_edge == 'true' }}
- name: "Runtime API: Build and push"
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ env.ARCHITECTURES }}
push: true
file: Dockerfile.runtime-api
provenance: false
build-args: |
BASE_IMAGE=ghcr.io/${{ env.NAMESPACE_GITHUB }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
tags: ${{ steps.rc_meta.outputs.tags }}
labels: ${{ steps.rc_meta.outputs.labels }}
docker-website:
needs: [config, docker]
if: needs.config.outputs.publish == 'true'
runs-on: ubuntu-latest
steps:
- name: Setup repo
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Website: Extract metadata (tags, labels) for docker"
id: website_meta
uses: docker/metadata-action@v5
with:
images: ${{ env.WEBSITE_IMAGE }}
tags: |
type=raw,value=${{ needs.config.outputs.website_tag }},enable=${{ needs.config.outputs.is_edge == 'true' }}
type=semver,pattern=latest,enable=true
- name: "Website: Build and push"
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.website
push: true
provenance: false
build-args: |
BASE_IMAGE=zefhemel/silverbullet:${{ needs.config.outputs.is_tag == 'true' && 'latest' || needs.config.outputs.docker_edge_tag }}
tags: ${{ steps.website_meta.outputs.tags }}
labels: ${{ steps.website_meta.outputs.labels }}