mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 19:27:41 +00:00
392bc15d91
* feat(git): swap isomorphic-git for native git transport behind clone seam Replace the isomorphic-git engine (HTTP-only, single importer) with the native git CLI behind the existing withClonedRepo seam, so SSH deploy keys, ref semantics, and private CAs become reachable in later PRs. - resolve-before-fetch: ls-remote pins the branch to an immutable SHA, then rev-parse verifies the checkout against it; tip races refuse - hardened spawns: argv arrays only, protocol allowlist (https only), neutralized hooks, isolated HOME and all config channels, no prompts - token reaches git only via a credential helper reading SENCHO_GIT_TOKEN from the child env; never argv or URL - size cap becomes a workspace watchdog (on-disk measure) keeping the same knob and breach message; deterministic final gate added - Windows: pin http.sslBackend=openssl (schannel ignores sslCAInfo) and anchor to Git's bundled CA; NODE_EXTRA_CA_CERTS combines with platform defaults instead of replacing them - error classification retargets to exit code + stderr while preserving the contractual mappings (AUTH_FAILED maps to 400, never 401; unauthenticated refusals mask as REPO_NOT_FOUND) - runtime image installs git; tests re-pointed at the transport boundary plus a new engine suite (classifier corpus, argv hardening, watchdog) Zero externally visible behavior change except two edge cases: an empty branch now surfaces BRANCH_NOT_FOUND, and a mid-fetch force push refuses instead of materializing the moved tip. * fix(git): unblock CI on linux kill-path test and codeql log warning Two CI-only findings from the first pipeline run: - The scripted spawn child in the transport tests lacked the kill method that killTree's POSIX fallback reaches when a fake process group does not exist; Linux runs crashed inside the timeout tests while Windows (taskkill branch) could not reproduce it. Give the fixture the method the real ChildProcess always has. - CodeQL flagged the workspace-removal warning that interpolated the NODE_EXTRA_CA_CERTS path (environment-sourced values are treated as sensitive at log sinks). Reword the warning to name the variable instead of its value; operators know their own environment. * fix(git): collapse remaining duplicated test setup so the shared helper is used * fix(git): close watchdog, size-gate, ref-validator, and kill-ordering gaps in native transport Resolves the release-blocking findings from an independent pre-merge audit of the native git transport swap: - A watchdog-triggered kill mid-clone was misclassified as a generic exit failure instead of a size breach, because runGit resolves (not rejects) when the child is killed via SIGKILL. - The final on-disk size measurement failed open when it could not be read (workspace removed mid-walk, permissions), letting an unmeasured clone through as a success. Now fails closed and logs the real cause. - The ref-name validator was an overly restrictive allow-list that rejected valid branch names (leading underscore, non-ASCII, '#'). Replaced with a deny-list matching real `git check-ref-format --branch` semantics, verified against the git binary, including a per-path-segment `.lock` check the first pass missed. - runGit's timeout handler settled as soon as a kill was issued rather than confirmed, racing workspace cleanup against a still-alive child tree. It now waits for the child's close event, with a bounded fallback if termination is never confirmed, and preserves the timeout classification if 'error' fires after the kill. - Windows killTree now also falls back to child.kill() when taskkill itself exits non-zero, not just when it fails to spawn. - Added a real, non-mocked integration test that drives the credential helper through the actual git binary against a local HTTPS server with Basic Auth checking. It caught a genuine bug the mocked suite could not see: the credential.helper config value was quoted in a way that broke git's own absolute-path helper detection, failing every authenticated clone. Fixed by removing the quotes. - Migrated a separately developed test file's mocks off the deleted isomorphic-git module onto the native transport seam, matching the pattern already used elsewhere, after merging with main pulled in that feature. Also updates two stale comments left over from the isomorphic-git era and adds a git version check to the Docker runtime image smoke tests. * fix(git): make credential-helper path safe, unify ref length, and fix Windows kill ordering Addresses three PR 1 correction items from pre-merge audit: - credential.helper is a shell string, not argv: interpolating the helper's workspace-relative path broke authenticated fetches whenever the workspace sat under a directory with a space in its name. The config value is now a fixed string that names an environment variable instead, so no workspace path character can affect how git's shell parses it. - The transport rejected branch names over 200 characters while the route accepted up to 256 and real git has no comparable limit. REF_MAX_LEN is now a single exported constant shared by the transport and both routes. - On Windows, taskkill runs as a separate process and could still be walking a killed process tree after the direct git child reported closed, letting the caller delete the workspace early. Kill operations are now awaited to completion (bounded by a timeout) before a timed-out or size-breached run settles, on both the close and error event paths. Verified against a real authenticated git server inside the built runtime image: public HTTPS, private HTTPS with a valid PAT, invalid PAT, a deleted branch, an oversized repository, and the awkward workspace-path case, including from a workspace path containing spaces and shell metacharacters. * fix(git): reap killed helpers and classify curl refusals
269 lines
13 KiB
YAML
269 lines
13 KiB
YAML
name: Build and Publish Docker Image
|
|
|
|
# Release-only path: fires on v* tag pushes and publishes the public release
|
|
# (Docker Hub + GHCR, latest/semver/moving-minor, SBOM, cosign, GitHub Release).
|
|
# Integration images for pre-release testing are built on every push to main by
|
|
# docker-dev.yml and published as ghcr.io/studio-saelix/sencho-dev:dev; nothing
|
|
# here runs for those. Keep release artifacts out of the dev path and vice versa.
|
|
on:
|
|
push:
|
|
tags:
|
|
- 'v*'
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
# Ref-scoped so two different release tags (e.g. v0.86.4 and v0.86.5) cannot
|
|
# cancel each other. Reruns of the exact same ref still cancel the prior
|
|
# run for that ref, which is the cancel behavior we actually want.
|
|
group: docker-publish-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
push_to_registry:
|
|
name: Push Docker image to Docker Hub and GHCR
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
# DOCKERHUB_USERNAME and DOCKERHUB_TOKEN live in the `production` environment,
|
|
# not repo-wide secrets. Any future workflow that tries to push to Docker Hub
|
|
# without declaring this environment will fail to resolve the credentials,
|
|
# which is exactly the blast-radius reduction we want. Adding a required
|
|
# reviewer to the environment in repo settings also turns every release into
|
|
# a manual-approval gate without any workflow change.
|
|
environment: production
|
|
permissions:
|
|
contents: write
|
|
# Required for cosign keyless signing via GitHub OIDC and for uploading
|
|
# SBOM/VEX files to GitHub Releases via softprops/action-gh-release.
|
|
id-token: write
|
|
# Required to push the multi-arch image to ghcr.io/studio-saelix/sencho
|
|
# using the auto-provisioned GITHUB_TOKEN. Docker Hub credentials still
|
|
# come from the production environment above.
|
|
packages: write
|
|
steps:
|
|
- name: Check out the repo
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
|
|
- name: Set up QEMU
|
|
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
|
|
with:
|
|
platforms: arm64
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # 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. Both the pre-publish scan build and the multi-arch
|
|
# push build below consume this so Trivy scans a fresh layer.
|
|
- name: Compute daily apk cache bust value
|
|
id: apk-bust
|
|
run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Log in to Docker Hub
|
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
|
with:
|
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
|
|
- name: Log in to GitHub Container Registry
|
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
|
with:
|
|
registry: ghcr.io
|
|
# repository_owner resolves to a fixed value (studio-saelix) on every
|
|
# event type. github.actor varies (a maintainer on workflow_dispatch,
|
|
# github-actions[bot] on the release tag push) and is just a label;
|
|
# GITHUB_TOKEN is what actually authenticates.
|
|
username: ${{ github.repository_owner }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Install cosign
|
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
|
|
|
- name: Extract metadata (tags, labels) for Docker
|
|
id: meta
|
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
|
|
with:
|
|
# Publish the same manifest under both registries so users on either
|
|
# platform can pull. Docker Hub remains primary for discoverability;
|
|
# GHCR mirrors at ghcr.io/studio-saelix/sencho with identical tags.
|
|
images: |
|
|
saelix/sencho
|
|
ghcr.io/studio-saelix/sencho
|
|
# On a v-tag push we publish:
|
|
# latest always points at the newest release
|
|
# X.Y.Z the immutable semver tag
|
|
# X.Y moving minor tag for users who want latest patch
|
|
# The pre-1.0 constraint hides the {{major}}-only tag until we ship 1.0,
|
|
# since on 0.x every minor is potentially breaking.
|
|
tags: |
|
|
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
|
|
# Build an amd64-only variant into the local daemon first so Trivy can
|
|
# scan the exact release artifact before it is tagged and pushed. This
|
|
# keeps vulnerable releases out of the `latest` and semver tags that
|
|
# users actually pull. Because the push-build step that follows runs in
|
|
# the same job against the same buildkit daemon, it reuses this build's
|
|
# layers from the daemon's in-memory cache (observed wall-time: the
|
|
# push-build typically finishes faster than the scan-build despite
|
|
# producing multi-arch output). The `cache-from` pull is just a
|
|
# cold-start fallback for the first-ever run or when the buildkit daemon
|
|
# is fresh; we intentionally do NOT write `cache-to` here because the
|
|
# push-build below writes a strictly better (multi-arch, mode=max)
|
|
# cache entry moments later. The tag lives in the `localhost/` namespace
|
|
# so a future `push: false` -> `push: true` mistake cannot publish it.
|
|
# Scanning only amd64 is a defensible proxy for the multi-arch release:
|
|
# distro package CVEs are arch-agnostic at the manifest level, and
|
|
# arch-specific container-relevant CVEs are extraordinarily rare.
|
|
- name: Build release image for pre-publish scan (amd64, loaded)
|
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
|
with:
|
|
context: .
|
|
push: false
|
|
load: true
|
|
platforms: linux/amd64
|
|
tags: localhost/sencho:release-scan
|
|
cache-from: type=registry,ref=saelix/sencho:buildcache
|
|
build-args: |
|
|
APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}
|
|
|
|
- name: Re-scan release image for vulnerabilities (Trivy)
|
|
# Gates the release on the same HIGH/CRITICAL policy as the PR scan.
|
|
# CVEs suppressed via the OpenVEX document (trivy.yaml -> security/vex/
|
|
# sencho.openvex.json) are the single source of truth across PR CI and
|
|
# release CI.
|
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
with:
|
|
image-ref: localhost/sencho:release-scan
|
|
exit-code: '1'
|
|
severity: 'CRITICAL,HIGH'
|
|
format: 'table'
|
|
trivy-config: trivy.yaml
|
|
|
|
# Start the scanned image headless on the runner and poll /api/health
|
|
# until it returns 200. Catches entrypoint regressions, native module
|
|
# ABI breakage, and first-boot crashes on the exact artifact that the
|
|
# multi-arch push below will republish moments later. Intentionally
|
|
# placed BEFORE the publish step so a smoke failure does not move
|
|
# `latest` or the semver tags on Docker Hub. The health endpoint is
|
|
# public and returns before any DB or Docker-socket work, so the
|
|
# container only needs a free port, no env vars, and no volume mounts.
|
|
- name: Smoke test release image (pre-publish)
|
|
run: |
|
|
set -euo pipefail
|
|
# Verify source-built Docker CLI, Compose, and git binaries are present and functional.
|
|
# These checks run in one container to avoid repeated start-up overhead.
|
|
docker run --rm --entrypoint sh localhost/sencho:release-scan -c \
|
|
'docker --version && docker compose version && git --version'
|
|
|
|
# Not using --rm so that a crashed container sticks around long
|
|
# enough for `docker logs` in the trap to surface the stack trace.
|
|
# The trap force-removes it explicitly whether it is still running
|
|
# or already exited.
|
|
docker run -d --name sencho-smoke -p 1852:1852 localhost/sencho:release-scan
|
|
trap 'docker logs sencho-smoke 2>&1 || true; docker rm -f sencho-smoke >/dev/null 2>&1 || true' EXIT
|
|
for i in $(seq 1 30); do
|
|
if curl -fsS http://localhost:1852/api/health >/dev/null 2>&1; then
|
|
echo "Container healthy after ${i}s"
|
|
curl -s http://localhost:1852/api/health
|
|
exit 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
echo "FAILED: /api/health did not become ready within 30s"
|
|
exit 1
|
|
|
|
- name: Build and push Docker image
|
|
id: build
|
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
|
with:
|
|
context: .
|
|
push: true
|
|
platforms: linux/amd64,linux/arm64
|
|
tags: ${{ steps.meta.outputs.tags }}
|
|
labels: ${{ steps.meta.outputs.labels }}
|
|
cache-from: type=registry,ref=saelix/sencho:buildcache
|
|
cache-to: type=registry,ref=saelix/sencho:buildcache,mode=max
|
|
build-args: |
|
|
APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}
|
|
# SBOM + provenance attestations are embedded as OCI referrers on the
|
|
# published image. Inspect with: docker buildx imagetools inspect <img>
|
|
sbom: true
|
|
provenance: mode=max
|
|
|
|
- name: Sign published image with cosign (keyless)
|
|
# Signs every tag produced by metadata-action using the ambient GitHub
|
|
# OIDC token. No private keys, no secrets. Users verify with:
|
|
# cosign verify saelix/sencho:<tag> \
|
|
# --certificate-identity-regexp "https://github.com/studio-saelix/sencho/.*" \
|
|
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
|
|
# Each ref is pinned to the immutable digest so signatures are bound to
|
|
# the exact manifest, not the mutable tag pointer.
|
|
env:
|
|
TAGS: ${{ steps.meta.outputs.tags }}
|
|
DIGEST: ${{ steps.build.outputs.digest }}
|
|
run: |
|
|
# Build a single cosign invocation with every tag pinned to the digest.
|
|
# All tags resolve to the same manifest, so one call signs them all and
|
|
# saves N-1 Rekor/Fulcio round trips. On workflow_dispatch $TAGS is empty
|
|
# and the refs array stays empty, so we no-op cleanly.
|
|
refs=()
|
|
while IFS= read -r tag; do
|
|
[ -n "$tag" ] || continue
|
|
refs+=("${tag}@${DIGEST}")
|
|
done <<< "$TAGS"
|
|
if [ ${#refs[@]} -gt 0 ]; then
|
|
cosign sign --yes "${refs[@]}"
|
|
else
|
|
echo "No tags to sign (likely a workflow_dispatch run on a non-tag ref)."
|
|
fi
|
|
|
|
- name: Generate CycloneDX SBOM
|
|
# syft scans the published manifest by digest for richer package
|
|
# extraction than the BuildKit-native SBOM. Also installs syft into
|
|
# PATH so the SPDX step below can reuse the OCI layer cache.
|
|
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.20.2
|
|
with:
|
|
image: saelix/sencho@${{ steps.build.outputs.digest }}
|
|
format: cyclonedx-json
|
|
output-file: sbom.cdx.json
|
|
upload-artifact: false
|
|
|
|
- name: Generate SPDX SBOM
|
|
# Reuses the syft binary and OCI layer cache from the prior step.
|
|
run: |
|
|
syft saelix/sencho@${{ steps.build.outputs.digest }} \
|
|
-o spdx-json=sbom.spdx.json
|
|
|
|
- name: Attest SBOMs and VEX with cosign (keyless)
|
|
# Attaches CycloneDX SBOM, SPDX SBOM, and OpenVEX document as signed
|
|
# OCI referrer attestations on the published digest, separately to each
|
|
# registry path. Attestations live next to the image manifest in the
|
|
# registry, so a verifier pulling from GHCR cannot resolve attestations
|
|
# written only to Docker Hub. Verification commands are documented in
|
|
# docs/operations/verifying-images.mdx.
|
|
env:
|
|
DIGEST: ${{ steps.build.outputs.digest }}
|
|
run: |
|
|
for IMAGE_REF in \
|
|
"saelix/sencho@${DIGEST}" \
|
|
"ghcr.io/studio-saelix/sencho@${DIGEST}"
|
|
do
|
|
cosign attest --yes --predicate sbom.cdx.json --type cyclonedx "${IMAGE_REF}"
|
|
cosign attest --yes --predicate sbom.spdx.json --type spdxjson "${IMAGE_REF}"
|
|
cosign attest --yes --predicate security/vex/sencho.openvex.json --type openvex "${IMAGE_REF}"
|
|
done
|
|
|
|
- name: Upload SBOM and VEX to GitHub Release
|
|
# Fallback for consumers who do not use cosign; files are also
|
|
# available as signed OCI attestations via the attest step above.
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2.3.3.0.2
|
|
with:
|
|
files: |
|
|
sbom.cdx.json
|
|
sbom.spdx.json
|
|
security/vex/sencho.openvex.json
|