Files
sencho/.github/workflows/docker-publish.yml
T
Anso 392bc15d91 feat(git): swap isomorphic-git for native git transport behind clone seam (#1849)
* 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
2026-08-28 02:00:08 +00:00

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