455 lines
15 KiB
YAML
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 }}
|