Skip to content

fix(config): ignore prototype-member algorithm preset names (#562) #147

fix(config): ignore prototype-member algorithm preset names (#562)

fix(config): ignore prototype-member algorithm preset names (#562) #147

name: docker-publish
on:
workflow_dispatch:
inputs:
publish_latest:
description: 'Also publish the latest tag'
default: 'true'
required: true
type: choice
options:
- 'true'
- 'false'
release_tag:
description: 'Tag to build (e.g. webssh2-server-v2.3.5); leave blank to use the selected ref'
default: ''
required: false
type: string
bundle_verify_outage_override:
description: >-
Bypass client-bundle verification ONLY for a confirmed GitHub
release / Attestations / Rekor outage. Tamper failures still block.
default: false
required: false
type: boolean
release:
types:
- published
push:
branches:
- main
# Only rebuild when files that actually affect the image change.
# Workflow-only and docs-only commits skip this build; `release:published`
# and `workflow_dispatch` are unaffected.
paths:
- 'Dockerfile'
- '.dockerignore'
- 'package.json'
- 'package-lock.json'
- 'app/**'
- 'index.ts'
- 'types/**'
- 'scripts/**'
- 'tsconfig.json'
- 'tsconfig.build.json'
- 'LICENSE'
- 'README.md'
- '.github/workflows/docker-publish.yml'
concurrency:
# Event-scoped so the release-publish run and the main-push run never
# contend for one slot. GitHub cancels a *pending* run when a newer run
# joins the same group (even with cancel-in-progress: false) — that
# starved the v5.0.1 release run, which lost its semver tags and notes.
group: image-publish-${{ github.event_name == 'push' && 'main' || 'release' }}
cancel-in-progress: false
permissions:
contents: read
jobs:
prepare:
runs-on: ubuntu-24.04
# Skip push events whose commit carries a release tag (the release event handles it)
if: |
github.event_name != 'push' ||
!startsWith(github.event.head_commit.message, 'chore(main): release webssh2-server')
permissions:
contents: read
env:
DOCKERHUB_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKER_TOKEN }}
# NOTE: only secret-free scalars are exposed as outputs. Image names and
# metadata tags/labels contain the owner ("billchurch"), which equals the
# DOCKER_USERNAME secret value — GitHub scrubs secret-containing job outputs
# to empty across jobs, so those are computed inside the consuming jobs
# (build = labels, merge = tags) instead of passed from here.
outputs:
has_release: ${{ steps.release_meta.outputs.has_release }}
semver_full: ${{ steps.release_meta.outputs.semver_full }}
semver_minor: ${{ steps.release_meta.outputs.semver_minor }}
semver_major: ${{ steps.release_meta.outputs.semver_major }}
effective_ref_name: ${{ steps.ctx.outputs.effective_ref_name }}
steps:
- name: Check required secrets
if: ${{ env.DOCKERHUB_USER == '' || env.DOCKERHUB_TOKEN == '' }}
run: |
echo '::error::DOCKER_USERNAME and DOCKER_TOKEN secrets are required' >&2
exit 1
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 0
fetch-tags: true
ref: ${{ inputs.release_tag != '' && inputs.release_tag || github.ref }}
- name: Normalize git ref context
id: ctx
env:
RELEASE_TAG_NAME: ${{ github.event.release.tag_name || inputs.release_tag || '' }}
run: |
ref="${GITHUB_REF:-}"
ref_name="${GITHUB_REF_NAME:-}"
release_tag="${RELEASE_TAG_NAME:-}"
if [ -n "$release_tag" ]; then
ref="refs/tags/$release_tag"
ref_name="$release_tag"
elif [ -z "$ref" ] && [ -n "$ref_name" ]; then
ref="refs/tags/$ref_name"
fi
if [ "$GITHUB_EVENT_NAME" = "push" ] && [[ "$ref_name" != webssh2-server-v* ]]; then
tag_at_head=$(git describe --tags --exact-match HEAD 2>/dev/null || echo "")
if [[ "$tag_at_head" =~ ^webssh2-server-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
ref="refs/tags/$tag_at_head"
ref_name="$tag_at_head"
fi
fi
{
echo "effective_ref=$ref"
echo "effective_ref_name=$ref_name"
} >> "$GITHUB_OUTPUT"
echo "Effective ref_name: $ref_name"
- name: Derive release semver tags
id: release_meta
env:
EFFECTIVE_REF_NAME: ${{ steps.ctx.outputs.effective_ref_name }}
run: |
ref_name="${EFFECTIVE_REF_NAME:-}"
prefix='webssh2-server-v'
if [[ "$ref_name" =~ ^${prefix}([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
major="${BASH_REMATCH[1]}"
minor="${BASH_REMATCH[2]}"
patch="${BASH_REMATCH[3]}"
{
echo "has_release=true"
echo "semver_full=${major}.${minor}.${patch}"
echo "semver_minor=${major}.${minor}"
echo "semver_major=${major}"
} >> "$GITHUB_OUTPUT"
else
echo "has_release=false" >> "$GITHUB_OUTPUT"
fi
build:
needs: prepare
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
permissions:
contents: read
packages: write
security-events: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
arch: amd64
runner: ubuntu-24.04
- platform: linux/arm64
arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 0
fetch-tags: true
ref: ${{ inputs.release_tag != '' && inputs.release_tag || github.ref }}
# No setup-qemu-action: each arch builds on its native runner.
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Compute OCI labels locally (image names contain the owner = a secret
# value, so they can't be passed as job outputs from prepare).
- id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: |
docker.io/billchurch/webssh2
ghcr.io/billchurch/webssh2
tags: |
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.action == 'published' }}
type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.publish_latest == 'true' }}
type=raw,value=main,enable=${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'release' && github.event.action == 'published') }}
type=raw,value=${{ needs.prepare.outputs.semver_full }},priority=900,enable=${{ needs.prepare.outputs.has_release == 'true' }}
type=raw,value=${{ needs.prepare.outputs.semver_minor }},enable=${{ needs.prepare.outputs.has_release == 'true' }}
type=raw,value=${{ needs.prepare.outputs.semver_major }},enable=${{ needs.prepare.outputs.has_release == 'true' }}
type=ref,event=branch,enable=${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref != 'refs/heads/main' }}
type=sha
# PHASE 1 (do not merge with the push build — keeps the Trivy gate
# fail-closed: scan must pass BEFORE anything is pushed).
- name: Build image for scan + smoke
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./Dockerfile
load: true
platforms: ${{ matrix.platform }}
tags: local/webssh2:scan
cache-from: type=gha,scope=publish-${{ matrix.arch }}-${{ github.ref }}
cache-to: type=gha,mode=max,scope=publish-${{ matrix.arch }}-${{ github.ref }}
- name: Trivy image scan (fail closed)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: local/webssh2:scan
format: sarif
output: trivy-image-${{ matrix.arch }}.sarif
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: '1'
cache: true
trivyignores: .trivyignore
# Keep --severity CRITICAL,HIGH applied to BOTH SARIF generation and
# exit-code evaluation; without this trivy-action unsets TRIVY_SEVERITY
# in sarif mode and silently disables the gate.
limit-severities-for-sarif: 'true'
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with:
sarif_file: trivy-image-${{ matrix.arch }}.sarif
category: trivy-image-${{ matrix.arch }}
- name: Smoke-test image
run: |
CID=$(docker run -d --rm -e DEBUG=webssh2:* local/webssh2:scan)
for _ in $(seq 1 30); do
if docker logs "$CID" 2>&1 | grep -q "server started successfully"; then
docker stop "$CID"
exit 0
fi
sleep 1
done
docker logs "$CID"
docker stop "$CID" || true
exit 1
# Verify the bundle baked into the image we are about to push matches the
# attested webssh2_client release (issue #547). The bundle is identical
# across arches, so this runs once on amd64. Pre-push and fail-closed.
- name: Setup Node.js for bundle verification
if: matrix.arch == 'amd64'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: 'npm'
- name: Install dependencies for bundle verification
if: matrix.arch == 'amd64'
# Only tsx + the pre-built webssh2_client package are needed; skip
# lifecycle scripts (no native builds required for verification).
run: npm ci --ignore-scripts
- name: Extract client bundle from built image
if: matrix.arch == 'amd64'
run: |
set -euo pipefail
docker create --name webssh2-bundle-verify local/webssh2:scan
docker cp webssh2-bundle-verify:/srv/webssh2/node_modules/webssh2_client/client ./image-client-bundle
docker rm webssh2-bundle-verify
- name: Verify image bundle integrity + provenance
if: matrix.arch == 'amd64'
env:
GH_TOKEN: ${{ github.token }}
WEBSSH2_CLIENT_DIR: ${{ github.workspace }}/image-client-bundle
WEBSSH2_BUNDLE_VERIFY_OUTAGE_OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.bundle_verify_outage_override && 'true' || 'false' }}
run: npm run security:verify-bundle
# PHASE 2: scan/smoke passed -> push this arch by digest to BOTH registries
# (gha cache makes this a near-instant re-export, no recompile).
- name: Build and push by digest
id: build
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: |
type=image,"name=docker.io/billchurch/webssh2,ghcr.io/billchurch/webssh2",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=publish-${{ matrix.arch }}-${{ github.ref }}
cache-to: type=gha,mode=max,scope=publish-${{ matrix.arch }}-${{ github.ref }}
- name: Export digest
run: |
mkdir -p digests
digest="${{ steps.build.outputs.digest }}"
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "::error::bad digest: $digest"; exit 1; }
echo "$digest" > "digests/${{ matrix.arch }}"
- name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digest-${{ matrix.arch }}
path: digests/*
if-no-files-found: error
retention-days: 5
merge:
needs: [prepare, build]
runs-on: ubuntu-24.04
permissions:
contents: write
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
pattern: digest-*
merge-multiple: true
path: digests
- name: Validate digests
run: |
for a in amd64 arm64; do
d=$(cat "digests/$a")
[[ "$d" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "::error::bad digest for $a: $d"; exit 1; }
done
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Compute the tag list locally (it contains the owner = a secret value, so
# it can't be passed as a job output from prepare). Same rules as build.
- id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: |
docker.io/billchurch/webssh2
ghcr.io/billchurch/webssh2
tags: |
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.action == 'published' }}
type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.publish_latest == 'true' }}
type=raw,value=main,enable=${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'release' && github.event.action == 'published') }}
type=raw,value=${{ needs.prepare.outputs.semver_full }},priority=900,enable=${{ needs.prepare.outputs.has_release == 'true' }}
type=raw,value=${{ needs.prepare.outputs.semver_minor }},enable=${{ needs.prepare.outputs.has_release == 'true' }}
type=raw,value=${{ needs.prepare.outputs.semver_major }},enable=${{ needs.prepare.outputs.has_release == 'true' }}
type=ref,event=branch,enable=${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref != 'refs/heads/main' }}
type=sha
- name: Create and verify multi-arch manifests (atomic, both registries)
id: manifest
env:
PREPARE_TAGS: ${{ steps.meta.outputs.tags }}
run: |
set -euo pipefail
AMD64=$(cat digests/amd64); ARM64=$(cat digests/arm64)
for NAME in docker.io/billchurch/webssh2 ghcr.io/billchurch/webssh2; do
mapfile -t TAGS < <(printf '%s\n' "${PREPARE_TAGS}" | grep "^${NAME}:")
if [ "${#TAGS[@]}" -eq 0 ]; then
echo "::error::no tags resolved for ${NAME}"; exit 1
fi
TAG_ARGS=()
for t in "${TAGS[@]}"; do TAG_ARGS+=(--tag "$t"); done
docker buildx imagetools create "${TAG_ARGS[@]}" \
"${NAME}@${AMD64}" "${NAME}@${ARM64}"
# Verify the just-created manifest lists both platforms.
plats=$(docker buildx imagetools inspect "${TAGS[0]}" \
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}} {{end}}')
case "$plats" in
*linux/amd64*) ;; *) echo "::error::${NAME} missing amd64: $plats"; exit 1;; esac
case "$plats" in
*linux/arm64*) ;; *) echo "::error::${NAME} missing arm64: $plats"; exit 1;; esac
echo "Verified ${NAME}: $plats"
done
- name: Update release notes with Docker images
if: ${{ needs.prepare.outputs.has_release == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.prepare.outputs.effective_ref_name }}
SEMVER_FULL: ${{ needs.prepare.outputs.semver_full }}
SEMVER_MINOR: ${{ needs.prepare.outputs.semver_minor }}
SEMVER_MAJOR: ${{ needs.prepare.outputs.semver_major }}
run: |
set -euo pipefail
IMAGE_DIGEST=$(docker buildx imagetools inspect \
"docker.io/billchurch/webssh2:${SEMVER_FULL}" --format '{{.Manifest.Digest}}')
[[ -n "$IMAGE_DIGEST" ]] || { echo "::error::empty manifest digest"; exit 1; }
CURRENT_BODY=$(gh release view "$RELEASE_TAG" --json body --jq '.body')
MARKER="<!-- docker-images:${SEMVER_FULL} -->"
if printf '%s' "$CURRENT_BODY" | grep -qF "$MARKER"; then
echo "::notice::Docker section already present for ${SEMVER_FULL}; skipping"
exit 0
fi
DOCKER_SECTION="
## 🐳 Docker Images
This release is available as multi-platform Docker images (linux/amd64, linux/arm64):
### Docker Hub
\`\`\`bash
docker pull billchurch/webssh2:latest
docker pull billchurch/webssh2:${SEMVER_FULL}
docker pull billchurch/webssh2:${SEMVER_MINOR}
docker pull billchurch/webssh2:${SEMVER_MAJOR}
\`\`\`
### GitHub Container Registry
\`\`\`bash
docker pull ghcr.io/billchurch/webssh2:latest
docker pull ghcr.io/billchurch/webssh2:${SEMVER_FULL}
docker pull ghcr.io/billchurch/webssh2:${SEMVER_MINOR}
docker pull ghcr.io/billchurch/webssh2:${SEMVER_MAJOR}
\`\`\`
**Image Digest:** \`${IMAGE_DIGEST}\`
**Links:**
- [Docker Hub Repository](https://hub.docker.com/r/billchurch/webssh2)
- [GitHub Container Registry](https://github.com/billchurch/webssh2/pkgs/container/webssh2)
${MARKER}
"
gh release edit "$RELEASE_TAG" --notes "${CURRENT_BODY}${DOCKER_SECTION}"