From 5948106353e736382ac7ccbb65a3b52237a1c4bc Mon Sep 17 00:00:00 2001 From: Gabor Demeter Date: Sun, 12 Jul 2026 14:26:24 +0200 Subject: [PATCH 1/3] Add: reusable docker build/test/push workflow (single image) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the per-image build → test → push chain from sf-docker-images/build-and-push.yml into a workflow_call workflow, per sf-docker-images/docs/reusable-workflow-migration-design.md. One invocation per image; callers fan out with a matrix and keep repo-specific release logic. Changes vs the source workflow: per-image gha cache scope, two-tag scheme ({{version}} + latest, rolling major/minor dropped), version-report artifact for release-note aggregation, README sync behind an image-description input, all third-party actions SHA-pinned. Co-Authored-By: Claude Fable 5 --- .github/workflows/docker-build-test-push.yml | 253 +++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 .github/workflows/docker-build-test-push.yml diff --git a/.github/workflows/docker-build-test-push.yml b/.github/workflows/docker-build-test-push.yml new file mode 100644 index 0000000..c508f0f --- /dev/null +++ b/.github/workflows/docker-build-test-push.yml @@ -0,0 +1,253 @@ +--- +# Reusable workflow: build → test → push for ONE Docker image. +# +# Callers fan out with a matrix (one invocation per image) and keep any +# repo-specific release logic locally. The test stage expects the caller repo +# to provide pytest-testinfra suites at tests/test_.py +# and a tests/requirements.txt. +# +# NOTE: this file's path is part of the cosign keyless certificate identity of +# every image signed by it. Renaming or moving it invalidates all documented +# `cosign verify` commands downstream. +name: Docker Build/Test/Push (Reusable) + +on: + workflow_call: + inputs: + image-name: + description: >- + Image name, e.g. sf-ci. Also selects tests/test_.py. + Must be unique per caller run (artifact names derive from it). + required: true + type: string + context: + description: 'Docker build context path, e.g. ./sf-ci' + required: true + type: string + push: + description: 'Push the multi-platform image to Docker Hub (set true only on release tags)' + required: false + default: false + type: boolean + image-description: + description: >- + Docker Hub short description; when set, the README at /README.md + is synced to Docker Hub after a successful push. + required: false + default: '' + type: string + dockerhub-username: + description: 'Docker Hub org/namespace' + required: false + default: 'gforceinnovation' + type: string + platforms: + description: 'Comma-separated platforms for the push stage' + required: false + default: 'linux/amd64,linux/arm64' + type: string + python-version: + description: 'Python version for the test stage' + required: false + default: '3.x' + type: string + artifact-retention-days: + description: 'Retention for the intermediate image tar and version-report artifacts' + required: false + default: 1 + type: number + secrets: + dockerhub-token: + description: 'Docker Hub access token (read/write; required only when push = true)' + required: false + +jobs: + build: + name: build + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build Docker image + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5.4.0 + with: + context: ${{ inputs.context }} + load: true + tags: ${{ inputs.image-name }}:test + cache-from: type=gha,scope=${{ inputs.image-name }} + cache-to: type=gha,mode=max,scope=${{ inputs.image-name }} + + - name: Report image size + run: | + SIZE=$(docker image inspect ${{ inputs.image-name }}:test --format='{{.Size}}') + echo "## ${{ inputs.image-name }}" >> "$GITHUB_STEP_SUMMARY" + echo "Image size: $(numfmt --to=iec-i --suffix=B "$SIZE")" >> "$GITHUB_STEP_SUMMARY" + + - name: Export Docker image + run: docker save ${{ inputs.image-name }}:test | gzip > ${{ inputs.image-name }}.tar.gz + + - name: Upload image artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ inputs.image-name }}-image + path: ${{ inputs.image-name }}.tar.gz + retention-days: ${{ inputs.artifact-retention-days }} + + test: + name: test + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + checks: write + pull-requests: write + security-events: write + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Download image artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ inputs.image-name }}-image + + - name: Load Docker image + run: docker load < ${{ inputs.image-name }}.tar.gz + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ inputs.python-version }} + + - name: Install test dependencies + run: pip install -r tests/requirements.txt + + - name: Run tests for ${{ inputs.image-name }} + run: | + IMAGE="${{ inputs.image-name }}" + TEST_FILE="test_${IMAGE//-/_}.py" + pytest "tests/${TEST_FILE}" -v --junitxml="test-results-${IMAGE}.xml" + + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@d0a4676d0e0b938bc201470d88276b7c74c712b3 # v2.24.0 + if: always() + with: + files: test-results-${{ inputs.image-name }}.xml + check_name: Test Results - ${{ inputs.image-name }} + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ inputs.image-name }}:test + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy results to GitHub Security + uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0 + if: always() + with: + sarif_file: 'trivy-results.sarif' + + push: + name: push + runs-on: ubuntu-latest + needs: test + if: ${{ inputs.push }} + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + # The single-arch tar is consumed by the version report below; the + # multi-arch push rebuilds from context (unavoidable for multi-platform). + - name: Download image artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ inputs.image-name }}-image + + - name: Load Docker image + run: docker load < ${{ inputs.image-name }}.tar.gz + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Log in to Docker Hub + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + username: ${{ inputs.dockerhub-username }} + password: ${{ secrets.dockerhub-token }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + with: + images: ${{ inputs.dockerhub-username }}/${{ inputs.image-name }} + # Two tags only: the exact release version (v1.2.3 -> 1.2.3) and latest. + # Rolling major/minor tags are deliberately not published. + tags: | + type=semver,pattern={{version}} + type=raw,value=latest + + - name: Push multi-platform image + id: push + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5.4.0 + with: + context: ${{ inputs.context }} + platforms: ${{ inputs.platforms }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ inputs.image-name }} + cache-to: type=gha,mode=max,scope=${{ inputs.image-name }} + sbom: true + provenance: true + + # Requires a read/write Docker Hub token (read-only tokens cannot update + # repository descriptions). + - name: Sync README to Docker Hub + if: ${{ inputs.image-description != '' }} + uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4.0.2 + with: + username: ${{ inputs.dockerhub-username }} + password: ${{ secrets.dockerhub-token }} + repository: ${{ inputs.dockerhub-username }}/${{ inputs.image-name }} + short-description: ${{ inputs.image-description }} + readme-filepath: ${{ inputs.context }}/README.md + + # Tool versions are read from the built image (source of truth, not the + # Dockerfile) and aggregated by the caller's release job. + - name: Build version report + run: | + IMG="${{ inputs.image-name }}:test" + run() { docker run --rm --entrypoint sh "$IMG" -c "$1"; } + OUT="version-report-${{ inputs.image-name }}.md" + { + echo "### \`${{ inputs.image-name }}\`" + echo + echo "| Component | Version |" + echo "| --- | --- |" + echo "| Node.js | $(run 'node -v') |" + echo "| npm | $(run 'npm -v') |" + echo "| Salesforce CLI | $(run 'sf version' | head -n1) |" + echo + echo "| Plugin | Version |" + echo "| --- | --- |" + # user = explicitly installed plugins only (core/jit would add ~40 rows) + run 'sf plugins --json' \ + | jq -r '.[] | objects | select(.type == "user") | "| \(.name) | \(.version) |"' + echo + } > "$OUT" + + - name: Upload version report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: version-report-${{ inputs.image-name }} + path: version-report-${{ inputs.image-name }}.md + retention-days: ${{ inputs.artifact-retention-days }} From b4185b8eac8fb7f5a9157bfe5eb4fc9b192f433b Mon Sep 17 00:00:00 2001 From: Gabor Demeter Date: Sun, 12 Jul 2026 14:26:44 +0200 Subject: [PATCH 2/3] Add: keyless cosign signing to docker push job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The push job now signs the pushed manifest-list digest with cosign (keyless, GitHub OIDC via Fulcio, recorded in Rekor). Requires id-token: write from the calling job. The certificate identity is this workflow's job_workflow_ref — renaming/moving this file invalidates downstream verify commands. Co-Authored-By: Claude Fable 5 --- .github/workflows/docker-build-test-push.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/docker-build-test-push.yml b/.github/workflows/docker-build-test-push.yml index c508f0f..15cc93e 100644 --- a/.github/workflows/docker-build-test-push.yml +++ b/.github/workflows/docker-build-test-push.yml @@ -161,6 +161,10 @@ jobs: if: ${{ inputs.push }} permissions: contents: read + # cosign keyless signing: the OIDC token is exchanged for a short-lived + # Fulcio certificate whose identity is THIS workflow's job_workflow_ref. + # Callers must grant id-token: write on the calling job. + id-token: write steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 @@ -209,6 +213,21 @@ jobs: sbom: true provenance: true + - name: Install cosign + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1 + + # Keyless signature over the pushed manifest-list digest, recorded in the + # Rekor transparency log. Verify with: + # cosign verify \ + # --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + # --certificate-identity-regexp \ + # '^https://github\.com/Gforce-Innovation-Kft/shared-github-actions/\.github/workflows/docker-build-test-push\.yml@.+$' \ + # /: + - name: Sign image with cosign (keyless OIDC) + env: + DIGEST: ${{ steps.push.outputs.digest }} + run: cosign sign --yes "${{ inputs.dockerhub-username }}/${{ inputs.image-name }}@${DIGEST}" + # Requires a read/write Docker Hub token (read-only tokens cannot update # repository descriptions). - name: Sync README to Docker Hub From eff3c3bc88e297f713134ff0200acb9540932c88 Mon Sep 17 00:00:00 2001 From: Gabor Demeter Date: Sun, 12 Jul 2026 14:28:25 +0200 Subject: [PATCH 3/3] Docs: document docker-build-test-push reusable workflow in README Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 10 ++++++++++ README.md | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index aad30d8..e571ff6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,16 @@ Runs Salesforce Code Analyzer (`forcedotcom/run-code-analyzer@v2`) with configur **Key inputs:** `workspace`, `fail-on-sev1-violations`, `fail-on-sev2-violations`, `max-violations`, `fail-on-changed-files-only` **Outputs:** `exit-code`, `num-violations`, `num-sev1-violations`, `num-sev2-violations` +### `docker-build-test-push` (`.github/workflows/docker-build-test-push.yml`) + +Builds, tests, and pushes **one** Docker image per invocation (callers matrix over their images). Stages: buildx build (per-image GHA cache scope, image tar artifact) → pytest-testinfra + JUnit check + Trivy SARIF → multi-arch Docker Hub push with SBOM/provenance, keyless cosign signing (OIDC), optional Docker Hub README sync, and a `version-report-` artifact for the caller's release job. Tag scheme: `{{version}}` + `latest` only. Designed for and consumed by `sf-docker-images`. + +**Key inputs:** `image-name` (unique per caller run — artifact names derive from it), `context`, `push` (default `false`; set from the caller's tag ref), `image-description` (enables README sync), `dockerhub-username`, `platforms`, `python-version`, `artifact-retention-days` +**Secrets:** `dockerhub-token` (read/write; only consumed when `push: true`) +**Caller permissions:** `contents: read`, `checks: write`, `pull-requests: write`, `security-events: write`, `id-token: write` (cosign keyless) +**Caller repo contract:** pytest suites at `tests/test_.py` + `tests/requirements.txt`. +**Do not rename/move this file** — its path is the cosign certificate identity (`job_workflow_ref`); renaming invalidates all documented `cosign verify` commands. + ### `test-simple` (`.github/workflows/test-simple.yml`) A minimal test workflow that echoes a message. Used for verifying cross-repo workflow calls work. diff --git a/README.md b/README.md index f2d22c1..1771ec2 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,19 @@ title/body, labels, and reviewers. - **`salesforce-code-analyzer.yml`** — run Salesforce Code Analyzer with quality gates; posts PR comments. Caller needs `pull-requests: write`, `contents: read`, `actions: read`. +- **`docker-build-test-push.yml`** — build → test → push for **one** Docker image + (callers fan out with a matrix). Buildx build with per-image GHA cache scope, + pytest-testinfra + Trivy SARIF test stage, multi-arch Docker Hub push with + SBOM/provenance, **keyless cosign signing**, optional Docker Hub README sync, + and a `version-report-` artifact (Node/npm/SF CLI/plugin versions read + from the built image) for release-note aggregation. Key inputs: `image-name`, + `context`, `push` (default `false`), `image-description`; secret: + `dockerhub-token` (only needed when `push: true`). Caller needs + `contents: read`, `checks: write`, `pull-requests: write`, + `security-events: write`, and `id-token: write` (cosign). Artifact names derive + from `image-name`, so it must be unique per caller run. Do **not** rename or + move the workflow file — its path is part of the cosign certificate identity, + and renaming breaks every documented `cosign verify` command. - **`test-simple.yml`** — minimal echo workflow for verifying cross-repo calls. ## Versioning