fix(config): ignore prototype-member algorithm preset names (#562) #147
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}" |