Files
sencho/.github/workflows/ci.yml
T
Anso 06d9ef5904 fix(docker): build compose plugin from ./cmd, not ./cmd/compose (#803)
* fix(docker): build compose plugin from ./cmd, not ./cmd/compose

The compose-builder stage in Dockerfile was building ./cmd/compose, which
in docker/compose v5.1.2 is `package compose` (cobra command definitions),
not the CLI plugin entry point. `go build -o file pkg` against a non-main
package writes a Go archive; COPY and chmod accept it, but the kernel
cannot exec an ar-format file, so the Docker CLI plugin manager rejected
the binary and surfaced only as `docker: unknown command: docker compose`
in the v0.64.0 release smoke gate. The plugin's actual main package is at
./cmd (cmd/main.go calls plugin.Run from the cli-plugins framework), per
the upstream Makefile in v5.1.2.

Also fix the version ldflag to target the v5 module path: in v5 the module
moved from github.com/docker/compose/v2 to .../v5, so the previous
-X .../v2/internal.Version= silently no-op'd and `docker compose version`
would have reported an empty string even after the build target fix.

Add -trimpath and -s -w to match the upstream Makefile's release flags.

Add an inline ELF-magic sanity check inside the compose-builder stage so
this exact failure mode (non-main package emitting an archive) fails the
build at the producer instead of surfacing 200 lines later. Hex match via
`od -tx1` works on both busybox and GNU coreutils; the named-character
form (`od -c`) renders fields differently across the two.

Add a binary smoke step to the docker-validate PR job that mirrors the
release-time gate, so a regression in either the CLI or compose source
build is caught at PR review time rather than at tag-push time.

* fix(vex): match Trivy purl format for bundled docker/docker version

The compose plugin build target fix exposes a real consequence: now that
compose-builder produces a true ELF binary (rather than a Go archive),
Trivy can read the embedded Go module metadata and correctly reports the
bundled docker/docker dependency as `v28.5.2+incompatible`. The existing
VEX subcomponent ID omitted the `+incompatible` Go-module suffix, so
Trivy's purl matcher rejected the suppression and CVE-2026-34040 surfaced
in the docker-validate scan against the rebuilt image.

Update the @id to the literal version Trivy emits and bump the document
version per OpenVEX spec. The justification (compose acts as a CLI
client, never as a daemon, so the authz-hook path is unreachable) is
unchanged and continues to apply to v28.5.2+incompatible identically.

* docs(security): explain why CVE-2026-34040 cannot be upgraded out

A direct in-build upgrade attempt (`go get github.com/docker/docker@<sha
of docker-v29.3.1>`) fails with:

  invalid version: go.mod has post-v1 module path
  "github.com/moby/moby/v2" at revision f78c987a

moby/moby migrated its Go module path to `github.com/moby/moby/v2` on
the docker-29.x branch. The legacy `github.com/docker/docker` import path
is therefore frozen at v28.5.2+incompatible from Go's perspective; the
proxy.golang.org listing for that module path confirms no v29.x version
is resolvable. compose v5.1.2 (and v5.1.3, the latest tag at the time of
writing) both still import the legacy path, so the bundled docker/docker
library cannot be moved past v28.5.2+incompatible until upstream compose
migrates its imports.

Document the constraint in two places so a future maintainer (or future
me) does not re-attempt the same `go get` and arrive at the same dead
end:

- Dockerfile compose-builder stage: comment block above the build step.
- security/vex/sencho.openvex.json: expand the CVE-2026-34040 statement's
  impact_statement to spell out the upstream module-split blocker. The
  not_affected status (compose runs as a CLI client, never executes the
  daemon authz hook code path) is the principled triage and remains the
  correct OpenVEX call, not a deferred upgrade.

No build or runtime behaviour changes. Pure documentation enrichment.

* fix(security): mirror CVE-2026-34040 VEX statement in .trivyignore

Trivy reports `Some vulnerabilities have been ignored/suppressed` but
still fails the docker-validate scan on CVE-2026-34040, because the
OpenVEX statement's product identifier `pkg:oci/sencho` cannot match
the build-local image tag `sencho:pr-test`. Trivy does not generate an
OCI purl for images that lack a registry digest (aquasecurity/trivy
issue 9399), so VEX product matching is a no-op at PR and release scan
time. The dual-layer pattern documented in .trivyignore's header was
designed for exactly this case: VEX is the canonical, attested,
published triage record, and .trivyignore mirrors entries that scan-time
matching cannot resolve, with a comment pointing to the corresponding
VEX justification.

The CVE-2026-34040 OpenVEX statement is unchanged (status=not_affected,
justification=vulnerable_code_not_in_execute_path). The .trivyignore
entry is a build-time scan filter only, with a referencing comment so
auditors land on the VEX file as the source of truth. CVE-2026-39883
already follows this pattern and continues to.
2026-04-27 09:10:21 -04:00

371 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
# Mirror of the release-time pre-publish smoke gate in docker-publish.yml.
# Catches the class of failure where the source-built Docker CLI or
# Compose plugin compiles cleanly but does not exec at runtime (e.g. -o
# pointed at a non-main Go package, ABI breakage in a base image bump).
# The release workflow additionally polls /api/health; PR CI keeps just
# the binary check to bound job duration without losing the gate.
- name: Smoke test built image (binaries)
run: |
set -euo pipefail
docker run --rm --entrypoint sh sencho:pr-test -c \
'docker --version && docker compose version'
# ---------------------------------------------------------------------------
# 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