mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
3668c71860
* feat(security): add SBOM attestations, VEX document, and retire .trivyignore Add OpenVEX triage document (security/vex/sencho.openvex.json) for the 5 residual CVEs vendored inside docker/compose v5.1.2 that were carried over from the previous PR. All 5 are marked not_affected with justifications. Configure Trivy in both CI and release workflows to consume the VEX document via trivy.yaml so the same source of truth gates PR scans and release scans. Delete .trivyignore, which is fully superseded by the VEX file. Add two new release pipeline steps after image publication: - CycloneDX 1.6 SBOM via anchore/sbom-action (also installs syft) - SPDX 2.3 SBOM via syft directly (reuses OCI layer cache from prior step) Both are attached as cosign OCI referrer attestations (keyless, OIDC-signed) and uploaded as GitHub Release assets alongside the OpenVEX file. Bump docker-publish.yml permissions from contents:read to contents:write, required for softprops/action-gh-release to create Release assets. Add docs/operations/verifying-images.mdx with copy-paste verification commands for all supply-chain artifacts: signature, SLSA provenance, CycloneDX SBOM, SPDX SBOM, OpenVEX, and Rekor entry. Update docs.json navigation and expand the Supply chain security section in docs/security.mdx. Add a Verifying Release Artifacts section to SECURITY.md. * fix(vex): cover otel SDK CVE-2026-39883 in rebuilt Docker CLI binary The rebuilt Docker CLI v29.4.0 vendors otel/sdk v1.42.0, which still contains CVE-2026-39883 (BSD kenv PATH hijacking; fixed in v1.43.0). docker-compose v5.1.2 vendors otel/sdk v1.38.0 separately. The original VEX statement only covered the compose binary's location and version, so Trivy's scan of /usr/local/bin/docker was not suppressed. Add a second subcomponent entry for the CLI binary path with the correct vendored version. The not_affected justification (BSD-only code path; we ship linux/amd64 and linux/arm64 only) holds for both binaries. * fix(ci): use list form for vulnerability.vex in trivy.yaml Trivy's config schema requires vulnerability.vex to be a list (mapped to the multi-value --vex flag). The previous bare-string value was silently dropped, so the OpenVEX document was never loaded and HIGH findings already covered by VEX statements still failed the scan. * fix(ci): mirror VEX CVE in .trivyignore for local-image scan Trivy does not emit an OCI purl for locally-built images without a RepoDigests entry (aquasecurity/trivy#9399), so OpenVEX product matching against the CI build target sencho:pr-test resolves to no artifact and every statement is silently dropped. The VEX document remains the canonical triage record and is still attached as a cosign attestation on the published image; this file just mirrors the single CVE that surfaces on the local scan so CI does not block on a finding already triaged in VEX. Updates the Trivy step comment to document the relationship between the two files.
359 lines
14 KiB
YAML
359 lines
14 KiB
YAML
name: Sencho CI
|
|
|
|
on:
|
|
pull_request:
|
|
branches: [main]
|
|
push:
|
|
branches: [main]
|
|
# Screenshot auto-merge commits only touch docs/images/.
|
|
# Ignoring this path breaks the cascade:
|
|
# merge PR → screenshots PR auto-merges → would re-trigger CI → now skipped.
|
|
# The next real merge will sync everything (rsync --delete does a full mirror).
|
|
paths-ignore:
|
|
- 'docs/images/**'
|
|
|
|
# Cancel previous runs on the same branch/PR
|
|
concurrency:
|
|
group: ci-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
# Least-privilege default for every job. Individual jobs may override (e.g.
|
|
# update-screenshots needs contents: write + pull-requests: write to open a PR).
|
|
permissions:
|
|
contents: read
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Build & Validation (PRs only, skipped for release-please PRs)
|
|
# ---------------------------------------------------------------------------
|
|
jobs:
|
|
backend:
|
|
name: Backend (Build, Test, Lint)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
# Skip on bot PRs that only touch version metadata (release-please) or
|
|
# doc screenshots. Neither changes the backend surface, so running the
|
|
# full build+test+lint+audit stack is pure action-minute waste.
|
|
if: >-
|
|
github.event_name == 'pull_request'
|
|
&& github.head_ref != 'release-please--branches--main--components--sencho'
|
|
&& github.head_ref != 'chore/refresh-screenshots'
|
|
steps:
|
|
- name: Checkout Code
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
|
with:
|
|
node-version: '20'
|
|
cache: 'npm'
|
|
cache-dependency-path: backend/package-lock.json
|
|
|
|
- name: Install Dependencies
|
|
working-directory: ./backend
|
|
run: npm ci
|
|
|
|
- name: Build (TypeScript)
|
|
working-directory: ./backend
|
|
run: npm run build
|
|
|
|
- name: Unit Tests (Vitest)
|
|
working-directory: ./backend
|
|
run: npm test
|
|
|
|
- name: Lint (ESLint)
|
|
working-directory: ./backend
|
|
run: npm run lint
|
|
|
|
- name: Audit Dependencies
|
|
working-directory: ./backend
|
|
run: npm audit --audit-level=high
|
|
|
|
# Hand the freshly-verified dist/ to the E2E job so it does not have to
|
|
# re-run `tsc` on the same source. retention-days is the minimum; this
|
|
# artifact is only needed for the downstream e2e job in the same run.
|
|
- name: Upload backend dist
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
with:
|
|
name: backend-dist
|
|
path: backend/dist
|
|
retention-days: 1
|
|
if-no-files-found: error
|
|
|
|
frontend:
|
|
name: Frontend (Build, Lint)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
# See backend job rationale: skip bot PRs that cannot affect the frontend.
|
|
if: >-
|
|
github.event_name == 'pull_request'
|
|
&& github.head_ref != 'release-please--branches--main--components--sencho'
|
|
&& github.head_ref != 'chore/refresh-screenshots'
|
|
steps:
|
|
- name: Checkout Code
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
|
with:
|
|
node-version: '20'
|
|
cache: 'npm'
|
|
cache-dependency-path: frontend/package-lock.json
|
|
|
|
- name: Install Dependencies
|
|
working-directory: ./frontend
|
|
run: npm ci
|
|
|
|
- name: Build (Vite/React)
|
|
working-directory: ./frontend
|
|
run: npm run build
|
|
|
|
- name: Lint (ESLint)
|
|
working-directory: ./frontend
|
|
run: npm run lint
|
|
|
|
- name: Audit Dependencies
|
|
working-directory: ./frontend
|
|
run: npm audit --audit-level=high
|
|
|
|
docker-validate:
|
|
name: Docker Build & Scan
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
# See backend job rationale.
|
|
if: >-
|
|
github.event_name == 'pull_request'
|
|
&& github.head_ref != 'release-please--branches--main--components--sencho'
|
|
&& github.head_ref != 'chore/refresh-screenshots'
|
|
steps:
|
|
- name: Checkout Code
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
|
|
|
# Computed once per job run. Feeds Dockerfile's APK_CACHE_BUST arg so
|
|
# the `apk upgrade` layer rebuilds at least once per calendar day, even
|
|
# when every other input to the layer is cached. See Dockerfile:115-122
|
|
# for the rationale.
|
|
- name: Compute daily apk cache bust value
|
|
id: apk-bust
|
|
run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Build Docker image (validation only)
|
|
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
|
|
with:
|
|
context: .
|
|
push: false
|
|
load: true
|
|
tags: sencho:pr-test
|
|
cache-from: type=gha
|
|
cache-to: type=gha,mode=max
|
|
build-args: |
|
|
APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}
|
|
|
|
- name: Scan image for vulnerabilities (Trivy)
|
|
# Hard-fails the PR on any HIGH or CRITICAL finding not suppressed by
|
|
# the OpenVEX document at security/vex/sencho.openvex.json (loaded via
|
|
# trivy.yaml) or by .trivyignore at the repo root. The VEX document is
|
|
# the canonical triage record and is also attached as a cosign
|
|
# attestation on the published image. .trivyignore exists only to
|
|
# mirror VEX entries that cannot be resolved here because Trivy does
|
|
# not emit an OCI purl for local images without a digest, so VEX
|
|
# product matching against `sencho:pr-test` is a no-op (see
|
|
# aquasecurity/trivy#9399). Add the VEX statement first, then mirror
|
|
# the CVE id in .trivyignore with a pointer to the VEX justification.
|
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
with:
|
|
image-ref: sencho:pr-test
|
|
exit-code: '1'
|
|
severity: 'CRITICAL,HIGH'
|
|
format: 'table'
|
|
trivy-config: trivy.yaml
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# E2E Tests (PRs only, skipped for release-please PRs)
|
|
# ---------------------------------------------------------------------------
|
|
e2e:
|
|
name: E2E Tests (Playwright)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
needs: [backend, frontend]
|
|
# See backend job rationale.
|
|
if: >-
|
|
github.event_name == 'pull_request'
|
|
&& github.head_ref != 'release-please--branches--main--components--sencho'
|
|
&& github.head_ref != 'chore/refresh-screenshots'
|
|
steps:
|
|
- name: Checkout Code
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
|
|
# Reuse the dist/ that the `backend` job produced and verified, instead
|
|
# of running `tsc` a second time against the same source tree. The
|
|
# composite action's `skip-backend-build` input short-circuits its build
|
|
# step when this artifact is already in place.
|
|
- name: Download backend dist
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
|
with:
|
|
name: backend-dist
|
|
path: backend/dist
|
|
|
|
- name: Start app & install Playwright
|
|
uses: ./.github/actions/start-app
|
|
with:
|
|
skip-backend-build: 'true'
|
|
|
|
- name: Run E2E tests
|
|
run: npx playwright test
|
|
|
|
- name: Upload E2E report and service logs
|
|
if: failure()
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
with:
|
|
name: playwright-report
|
|
path: |
|
|
e2e/report/
|
|
test-results/
|
|
ci-logs/
|
|
retention-days: 7
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Post-release only: Screenshot refresh
|
|
# Only runs when release-please merges a "chore(main): release" commit,
|
|
# so screenshots are refreshed once per release - not on every merge.
|
|
# ---------------------------------------------------------------------------
|
|
update-screenshots:
|
|
name: Refresh Doc Screenshots
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
if: >-
|
|
github.event_name == 'push'
|
|
&& github.ref == 'refs/heads/main'
|
|
&& startsWith(github.event.head_commit.message, 'chore(main): release')
|
|
permissions:
|
|
contents: write
|
|
pull-requests: write
|
|
steps:
|
|
# Mint a short-lived installation token for the `sencho-token-app`
|
|
# GitHub App. Reused by the checkout, PR creation, and auto-merge
|
|
# steps below so we make exactly one /app/installations token call.
|
|
- name: Generate GitHub App installation token
|
|
id: app-token
|
|
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
|
with:
|
|
app-id: ${{ secrets.APP_ID }}
|
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
|
owner: ${{ github.repository_owner }}
|
|
repositories: Sencho
|
|
permission-contents: write
|
|
permission-pull-requests: write
|
|
|
|
- name: Checkout Code
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
with:
|
|
token: ${{ steps.app-token.outputs.token }}
|
|
|
|
- name: Start app & install Playwright
|
|
uses: ./.github/actions/start-app
|
|
|
|
- name: Capture screenshots
|
|
run: npx playwright test e2e/screenshots.spec.ts --project=chromium
|
|
|
|
- name: Open / update screenshots PR
|
|
id: create-pr
|
|
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
|
with:
|
|
token: ${{ steps.app-token.outputs.token }}
|
|
branch: chore/refresh-screenshots
|
|
commit-message: "docs: refresh screenshots"
|
|
title: "docs: refresh screenshots"
|
|
body: "Automated screenshot refresh - generated by the `update-screenshots` CI job on push to `main`."
|
|
add-paths: docs/images/
|
|
delete-branch: true
|
|
|
|
- name: Auto-merge screenshots PR
|
|
if: steps.create-pr.outputs.pull-request-number != ''
|
|
env:
|
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
run: |
|
|
gh pr merge ${{ steps.create-pr.outputs.pull-request-number }} \
|
|
--squash \
|
|
--repo ${{ github.repository }}
|
|
|
|
sync-docs:
|
|
name: Sync Docs to sencho-docs
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 5
|
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
steps:
|
|
# This job mirrors docs/ into the sibling sencho-docs repo. The token
|
|
# must therefore be scoped to `sencho-docs`, not the current repo, so
|
|
# we mint an installation token explicitly targeting that repo.
|
|
# Minted unconditionally so the step graph stays simple; the token
|
|
# auto-revokes on post-step even when the gate below is false.
|
|
- name: Generate GitHub App installation token
|
|
id: app-token
|
|
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
|
with:
|
|
app-id: ${{ secrets.APP_ID }}
|
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
|
owner: ${{ github.repository_owner }}
|
|
repositories: sencho-docs
|
|
permission-contents: write
|
|
|
|
- name: Checkout Sencho repo
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
with:
|
|
path: sencho
|
|
# Need the previous commit to diff against for the docs-change gate.
|
|
fetch-depth: 2
|
|
|
|
# Skip the clone/rsync/commit work entirely when the push doesn't touch
|
|
# docs/. rsync --delete is a no-op in that case anyway, but spinning up
|
|
# the runner + cloning sencho-docs + computing the diff still burns
|
|
# ~30s of action minutes per non-docs push to main. With multiple
|
|
# non-docs merges per day this adds up fast.
|
|
- name: Detect docs changes
|
|
id: detect
|
|
working-directory: sencho
|
|
run: |
|
|
if git diff --name-only HEAD~1 HEAD -- docs/ | grep -q .; then
|
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
|
echo "No changes under docs/ in this commit; skipping sync."
|
|
fi
|
|
|
|
- name: Clone or init sencho-docs
|
|
if: steps.detect.outputs.changed == 'true'
|
|
env:
|
|
DOCS_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
run: |
|
|
rm -rf sencho-docs
|
|
|
|
REMOTE="https://x-access-token:${DOCS_TOKEN}@github.com/AnsoCode/sencho-docs.git"
|
|
|
|
if git clone "${REMOTE}" sencho-docs 2>/dev/null; then
|
|
echo "Cloned existing sencho-docs."
|
|
else
|
|
echo "Clone failed (repo is empty or has no default branch) - initializing."
|
|
mkdir -p sencho-docs
|
|
cd sencho-docs
|
|
git init
|
|
git checkout -b main
|
|
git remote add origin "${REMOTE}"
|
|
fi
|
|
|
|
- name: Copy /docs into sencho-docs root
|
|
if: steps.detect.outputs.changed == 'true'
|
|
run: rsync -av --delete --exclude='.git' sencho/docs/ sencho-docs/
|
|
|
|
- name: Commit and push to sencho-docs
|
|
if: steps.detect.outputs.changed == 'true'
|
|
working-directory: sencho-docs
|
|
run: |
|
|
git config user.email "docs-bot@sencho.io"
|
|
git config user.name "Sencho Docs Bot"
|
|
git add -A
|
|
git commit -m "docs: sync from main@${{ github.sha }}" || echo "No changes to commit"
|
|
git push origin HEAD:main
|