Skip to content

Merge pull request #265 from sheaf-project/release-v1.3.6 #641

Merge pull request #265 from sheaf-project/release-v1.3.6

Merge pull request #265 from sheaf-project/release-v1.3.6 #641

Workflow file for this run

name: CI
#
on:
push:
# preview/** gets the full build+publish flow so a feature line can be
# deployed to a test instance before it is anywhere near a release. The
# release approval gate only ever applies to tags, so preview images
# publish once lint and tests pass, tagged with the commit sha and a
# sanitised branch-name tag (preview/foo -> preview-foo).
branches: [main, "preview/**"]
tags: ["v*"]
pull_request:
branches: [main]
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install backend deps
run: pip install ruff
- name: Lint backend
run: ruff check sheaf/
# Runs against the PR merge result, so a migration that collided with
# another PR (duplicate revision id, or a second head off the same
# parent) fails here in seconds with a clear message instead of as a
# slow "app did not become ready" timeout in the test job.
- name: Check migration graph
run: python scripts/check_migrations.py
- name: Install frontend deps
run: cd web && npm ci
- name: Lint frontend
run: cd web && npm run lint
- name: Type check frontend
run: cd web && npx tsc --noEmit
test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install test deps
run: uv pip install --system -e ".[dev]"
- name: Run test suite
run: ./run_tests.sh
# Fast pre-checks for a tagged release, BEFORE the approval gate, so an
# obviously-bad tag (not on main, or a version mismatch) fails in seconds
# without bothering the approver.
preflight:
name: Release preflight
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # ancestor check needs full history
- name: Validate tag is on main
run: |
git fetch origin main
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::tagged commit $GITHUB_SHA is not on main - merge first, then tag"
exit 1
fi
- name: Verify pyproject.toml version matches tag
run: |
TAG="${GITHUB_REF_NAME#v}"
PY=$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "(.*)"/\1/')
if [ "$TAG" != "$PY" ]; then
echo "::error::tag ${GITHUB_REF_NAME} does not match pyproject.toml version ${PY}"
exit 1
fi
echo "ok: tag ${GITHUB_REF_NAME} matches pyproject.toml ${PY}"
# Manual-approval gate for a tagged release, placed at the START of the
# release flow (right after preflight) so approval is not blocked behind the
# ~15-minute test run. Configure repo Settings -> Environments -> "release"
# with required reviewers. The build/publish jobs below wait on this for tag
# pushes; on a branch push it is skipped and they proceed normally.
approve-release:
name: Approve release
if: startsWith(github.ref, 'refs/tags/v')
needs: [preflight]
runs-on: ubuntu-latest
environment: release
steps:
- run: echo "Release ${{ github.ref_name }} approved."
docker:
runs-on: ubuntu-latest
needs: [lint, test, approve-release]
# Any push, once lint + test pass. A tag push additionally waits for the
# approval gate above; on a branch push approve-release is skipped, which
# the skipped-result check tolerates.
if: >-
always() &&
github.event_name == 'push' &&
needs.lint.result == 'success' &&
needs.test.result == 'success' &&
(needs.approve-release.result == 'success' || needs.approve-release.result == 'skipped')
permissions:
contents: read
packages: write
id-token: write # sigstore/cosign keyless OIDC
strategy:
fail-fast: false
matrix:
include:
- name: sheaf
image: sheaf
dockerfile: Dockerfile.backend
build_args: ""
- name: sheaf-devtools
image: sheaf-devtools
dockerfile: Dockerfile.backend
build_args: |
INCLUDE_DEV_TOOLS=true
- name: sheaf-web
image: sheaf-web
dockerfile: Dockerfile.web
build_args: ""
- name: sheaf-aio
image: sheaf-aio
dockerfile: Dockerfile.aio
build_args: ""
steps:
- uses: actions/checkout@v4
# Tag validity (on main, version matches) is checked in the preflight
# job that gates this one on tag pushes, so no per-image check here.
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}
tags: |
type=sha,format=long
type=raw,value=head,enable={{is_default_branch}}
type=ref,event=branch,enable=${{ github.ref_name != 'main' }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
# latest=auto moves :latest only for a stable semver tag. The old
# raw rule fired on ANY v* tag, so the first v1.x.y-beta.N we ever
# cut would have repointed :latest for every deploy that pulls it.
# A prerelease (hyphenated) or malformed tag now gets only its own
# version tag, or nothing.
flavor: |
latest=auto
- uses: docker/build-push-action@v6
id: build
with:
context: .
file: ${{ matrix.dockerfile }}
push: true
platforms: linux/amd64,linux/arm64
build-args: |
${{ matrix.build_args }}
GIT_COMMIT=${{ github.sha }}
GIT_TAG=${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
BUILD_TIME=${{ github.event.head_commit.timestamp || github.run_started_at }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Install syft
uses: anchore/sbom-action/download-syft@v0
# Sign by digest. cosign stores the signature at <repo>:sha256-DIGEST.sig
# so a single sign call covers every tag pointing at the same digest.
# The retry wrapper covers the GH Actions OIDC token endpoint flaking
# under load - signing is idempotent at the registry layer.
- name: Sign image
env:
REF: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}@${{ steps.build.outputs.digest }}
run: |
./.github/scripts/with-retry.sh cosign sign --yes \
-a "git_sha=${{ github.sha }}" \
-a "git_ref=${{ github.ref }}" \
-a "workflow=${{ github.workflow }}" \
-a "run_id=${{ github.run_id }}" \
"$REF"
- name: Generate SBOM (SPDX)
env:
REF: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}@${{ steps.build.outputs.digest }}
run: syft "$REF" -o spdx-json > sbom.spdx.json
- name: Attest SBOM
env:
REF: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}@${{ steps.build.outputs.digest }}
run: |
./.github/scripts/with-retry.sh cosign attest --yes \
--predicate sbom.spdx.json \
--type spdxjson \
"$REF"
# Build manifest attestation — only for sheaf-web. Re-run the frontend
# build on the runner to produce dist/build-manifest.json, then attest
# it against the image. Same machine + same envs as the docker build,
# so the resulting manifest mirrors what's served from the image.
- name: Setup Node (manifest)
if: matrix.name == 'sheaf-web'
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build frontend manifest
if: matrix.name == 'sheaf-web'
env:
VITE_GIT_COMMIT: ${{ github.sha }}
VITE_GIT_TAG: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
VITE_BUILD_TIME: ${{ github.event.head_commit.timestamp || github.run_started_at }}
run: |
cd web
npm ci
npm run build
- name: Attest build manifest
if: matrix.name == 'sheaf-web'
env:
REF: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}@${{ steps.build.outputs.digest }}
run: |
./.github/scripts/with-retry.sh cosign attest --yes \
--predicate web/dist/build-manifest.json \
--type custom \
"$REF"
# Publishes the GitHub release page + verifiable artefacts. The manual
# approval gate now runs up front (see the approve-release job), and preflight
# already checked the tag, so by the time this runs the release is approved
# and validated; it just needs the signed images from the docker job.
release:
name: Publish GitHub release
if: startsWith(github.ref, 'refs/tags/v')
needs: [docker]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build frontend
env:
VITE_GIT_COMMIT: ${{ github.sha }}
VITE_GIT_TAG: ${{ github.ref_name }}
VITE_BUILD_TIME: ${{ github.event.head_commit.timestamp || github.run_started_at }}
run: |
cd web
npm ci
npm run build
- name: Package frontend tarball
run: |
tar czf "web-dist-${GITHUB_REF_NAME}.tar.gz" -C web/dist .
# Extract this version's section out of CHANGELOG.md for the release
# body. Falls through to empty if the section is missing — auto-notes
# still get appended either way.
- name: Extract changelog section
run: |
awk -v ver="${GITHUB_REF_NAME}" '
$0 ~ "^## \\[" ver "\\]" { capture=1; next }
capture && /^## \[/ { exit }
capture { print }
' CHANGELOG.md > release-body.md || true
if [ ! -s release-body.md ]; then
echo "(no CHANGELOG.md section for ${GITHUB_REF_NAME})" > release-body.md
fi
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
body_path: release-body.md
generate_release_notes: true
prerelease: ${{ startsWith(github.ref_name, 'v0.') }}
files: |
web-dist-${{ github.ref_name }}.tar.gz
web/dist/build-manifest.json
# Let downstream tooling know a release shipped. Target and token
# live in secrets so they never appear in this file or in logs;
# skips quietly when unset (forks, dry runs).
- name: Notify docs tooling
if: success()
env:
DISPATCH_REPO: ${{ secrets.DOCS_DISPATCH_REPO }}
DISPATCH_TOKEN: ${{ secrets.DOCS_DISPATCH_TOKEN }}
run: |
if [ -z "$DISPATCH_REPO" ] || [ -z "$DISPATCH_TOKEN" ]; then
echo "docs dispatch not configured; skipping"
exit 0
fi
curl -fsS -X POST \
"https://api.github.com/repos/${DISPATCH_REPO}/dispatches" \
-H "Authorization: Bearer ${DISPATCH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data "{\"event_type\":\"release-web\",\"client_payload\":{\"platform\":\"web\",\"ref\":\"${GITHUB_REF_NAME}\"}}"