diff --git a/.dockerignore b/.dockerignore index 9722a63..f04d40e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,9 +10,10 @@ tests/ app_tests/ -# Docs and scripts (not needed in image) +# Docs and scripts (not needed in image, except the heavy image entrypoint) docs/ -scripts/ +scripts/* +!scripts/docker-heavy-entrypoint.sh # Markdown (keep README.md — it's copied explicitly in the Dockerfile) *.md diff --git a/.github/workflows/_docker-pipeline.yml b/.github/workflows/_docker-pipeline.yml index 15fe12b..147485a 100644 --- a/.github/workflows/_docker-pipeline.yml +++ b/.github/workflows/_docker-pipeline.yml @@ -1,13 +1,16 @@ name: _docker-pipeline (reusable) -# Reusable workflow — the single lego brick for all Docker CI steps. +# Reusable workflow — the per-arch lego brick for all Docker CI steps. # -# Called by smoke-test.yml (push: false) and publish-docker.yml (push: true). -# Step visibility is controlled by the push/tag_push inputs; the caller sets permissions. +# Called by smoke-test.yml, dependency-review.yml (push: false) and +# publish-docker.yml (push: true, once per arch via matrix). # # Two modes: # push: false → build + smoke test + integration test (main image only) -# push: true → above + push exact version tags to GHCR/Docker Hub +# push: true → above + push the built image by digest to GHCR + Docker Hub, +# and upload the resulting digest as an artifact. The caller's +# merge-manifests job assembles per-arch digests into a +# multi-arch manifest list at the user-facing tags. # # Permissions required from the calling workflow: # push: false → contents: read @@ -33,25 +36,40 @@ on: description: "Smoke-test tool set: main or app-tests" type: string required: true - push: - description: "Push to GHCR and Docker Hub after testing" - type: boolean + runs_on: + description: "Runner label (e.g. ubuntu-latest, ubuntu-24.04-arm). The build/test steps run natively on this arch." + type: string required: false - default: false - tag_push: + default: "ubuntu-latest" + arch_label: + description: "Short arch identifier used for digest artifact name and cache scope (e.g. amd64, arm64). Required when push=true." + type: string + required: false + default: "" + push_name: description: > - True when the caller was triggered by a tag push (e.g. v2.0.0). - Controls semver metadata-action tagging for exact release tags. - Passed explicitly rather than relying on github.ref_type inside the callee, - since context propagation in reusable workflows can be ambiguous. + Registry repository to push to (defaults to name). Image variants share + the base repository and are distinguished by tag suffix (e.g. -heavy), + so the heavy build passes name=socket-basics-heavy (local tag, cache + scope, digest artifact) with push_name=socket-basics. + type: string + required: false + default: "" + push: + description: "Push to GHCR + Docker Hub by digest after testing. The publish workflow merges per-arch digests into a multi-arch manifest list." type: boolean required: false default: false version: - description: "Semver without v prefix (e.g. 2.0.0) — used for OCI labels and push tags" + description: "Semver without v prefix (e.g. 2.0.0) — passed as the SOCKET_BASICS_VERSION build-arg, baked into OCI labels" type: string required: false default: "dev" + ref: + description: "Git ref to check out. Publish mode passes the resolved release tag so builds use the release source." + type: string + required: false + default: "" secrets: DOCKERHUB_USERNAME: required: false @@ -60,15 +78,20 @@ on: jobs: pipeline: - runs-on: ubuntu-latest + runs-on: ${{ inputs.runs_on }} timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + ref: ${{ inputs.ref }} persist-credentials: false + - name: Resolve source revision + id: source + run: echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: 🔨 Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -87,31 +110,10 @@ jobs: # requests including pulling public base images (python, trivy, trufflehog). # Those public images pull fine without auth; only the push needs credentials. - - name: Extract image metadata - if: inputs.push - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - with: - images: | - ghcr.io/socketdev/${{ inputs.name }} - ${{ secrets.DOCKERHUB_USERNAME }}/${{ inputs.name }} - # Disable the automatic :latest tag — metadata-action adds it by default - # for semver tag pushes. Mutable tags are inappropriate for a security tool. - flavor: | - latest=false - tags: | - # Tag push (v2.0.0) → exact immutable version tag only. - # Minor (2.0) and latest tags are intentionally omitted. - type=semver,pattern={{version}} - # workflow_dispatch re-publish → use the version input directly - type=raw,value=${{ inputs.version }},enable=${{ !inputs.tag_push }} - labels: | - org.opencontainers.image.title=${{ inputs.name }} - org.opencontainers.image.source=https://github.com/SocketDev/socket-basics - # ── Step 1: Build ────────────────────────────────────────────────────── # Loads image into the local Docker daemon without pushing. - # Writes all layers to the GHA cache so the push step is just an upload. + # Per-arch cache scope ensures amd64 and arm64 builds don't pollute each + # other's layer cache. arch_label defaults to "smoke" when push=false. - name: 🔨 Build (load for testing) uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: @@ -123,10 +125,10 @@ jobs: tags: ${{ inputs.name }}:pipeline-test build-args: | SOCKET_BASICS_VERSION=${{ inputs.version }} - VCS_REF=${{ github.sha }} + VCS_REF=${{ steps.source.outputs.revision }} BUILD_DATE=${{ github.event.repository.updated_at }} - cache-from: type=gha,scope=${{ inputs.name }} - cache-to: type=gha,mode=max,scope=${{ inputs.name }} + cache-from: type=gha,scope=${{ inputs.name }}-${{ inputs.arch_label || 'smoke' }} + cache-to: type=gha,mode=max,scope=${{ inputs.name }}-${{ inputs.arch_label || 'smoke' }} # Disable attestations for the test build — provenance/SBOM cause BuildKit # to pull docker/buildkit-syft-scanner from Docker Hub, which fails with a # repo-scoped token. Attestations are enabled on the push step only. @@ -144,16 +146,16 @@ jobs: --image-tag "$IMAGE_NAME:pipeline-test" \ --check-set "$CHECK_SET" - # ── Step 3: Integration test (main image only) ───────────────────────── + # ── Step 3: Integration test (socket-basics variants only) ───────────── - name: 🔬 Integration test - if: inputs.name == 'socket-basics' + if: inputs.name == 'socket-basics' || inputs.name == 'socket-basics-heavy' env: IMAGE_NAME: ${{ inputs.name }} run: | bash ./scripts/integration-test-docker.sh \ --image-tag "$IMAGE_NAME:pipeline-test" - # ── Step 4: Push to registries (publish mode only) ───────────────────── + # ── Step 4: Push by digest (publish mode only) ───────────────────────── # Docker Hub login happens here — after build and tests, immediately before # push. Keeping it here prevents the repo-scoped token from interfering # with public image pulls during the build step. @@ -164,30 +166,50 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - # All layers are in the GHA cache from step 1 — this is just an upload. - - name: 🚀 Push to registries + # Per-arch by-digest push to BOTH registries. No tags are written here; + # the publish workflow's merge-manifests job creates the multi-arch + # manifest list at user-facing tags via `docker buildx imagetools create`. + # Layer cache from step 1 means this is mostly a metadata write + push. + - name: 🚀 Build & push by digest if: inputs.push + id: build-digest uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: # zizmor: ignore[template-injection] — safe: always hardcoded "." from same-repo callers; passed as array element to exec, not shell-interpolated context: ${{ inputs.context }} file: ${{ inputs.dockerfile }} - load: false - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} build-args: | SOCKET_BASICS_VERSION=${{ inputs.version }} - VCS_REF=${{ github.sha }} + VCS_REF=${{ steps.source.outputs.revision }} BUILD_DATE=${{ github.event.repository.updated_at }} - cache-from: type=gha,scope=${{ inputs.name }} + # One `--output` per registry → blobs land in both, by digest. + # build-push-action splits this scalar on newlines into separate outputs. + outputs: | + type=image,name=ghcr.io/socketdev/${{ inputs.push_name || inputs.name }},push-by-digest=true,name-canonical=true,push=true + type=image,name=${{ secrets.DOCKERHUB_USERNAME }}/${{ inputs.push_name || inputs.name }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ inputs.name }}-${{ inputs.arch_label }} + cache-to: type=gha,mode=max,scope=${{ inputs.name }}-${{ inputs.arch_label }} # SBOM and provenance generation pull docker/buildkit-syft-scanner from # Docker Hub, which fails with a repo-scoped token. Disabled until a # token with broader Docker Hub read access is available. provenance: false sbom: false - # Floating major version tags (v2 → latest v2.x.y) have been intentionally - # removed. Mutable tags are structurally equivalent to :latest and are - # inappropriate for a security tool. Users should pin to an immutable - # version tag or digest and use Dependabot to manage upgrades. + # Persist the per-arch digest as an artifact so the merge-manifests job + # can reference it via `@sha256:` when creating the list. + - name: 📤 Export digest + if: inputs.push + env: + DIGEST: ${{ steps.build-digest.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: ⬆️ Upload digest artifact + if: inputs.push + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: digests-${{ inputs.name }}__${{ inputs.arch_label }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 47aab71..e9110a7 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -1,11 +1,18 @@ name: publish-docker -# Builds, tests, and publishes the socket-basics image to GHCR and Docker Hub. +# Builds, tests, and publishes multi-arch socket-basics image variants +# (linux/amd64 + linux/arm64) to GHCR and Docker Hub. # -# Flow: resolve-version → build-test-push → create-release +# Flow: +# resolve-version +# → build-test-push (matrix: image variant + native arch, pushes by digest) +# → merge-manifests (assembles per-image per-arch digests into manifest lists) +# → create-release (tag pushes only) # # Tag convention: # v2.0.0 — immutable exact release (floating major tags intentionally not published) +# All image variants publish to the single socket-basics repository per +# registry, distinguished by tag suffix: 2.0.0 (main), 2.0.0-heavy (heavy). # See docs/github-action.md → "Pinning strategies" for the full rationale. # # Required repository secrets: @@ -19,7 +26,7 @@ on: workflow_dispatch: inputs: tag: - description: "Full git tag to publish (e.g. v2.0.0 for new releases, 1.1.3 for old). Must exist in the repo." + description: "Full git tag to publish (e.g. v2.0.3 or 2.0.3). Must exist in the repo." required: true # Default: deny everything. Each job below grants only what it needs. @@ -38,56 +45,260 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.clean }} + ref: ${{ steps.version.outputs.ref }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} - persist-credentials: false - - name: 🏷️ Resolve version id: version env: EVENT_NAME: ${{ github.event_name }} INPUT_TAG: ${{ inputs.tag }} REF_NAME: ${{ github.ref_name }} + REPO_URL: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git run: | if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - CLEAN="$INPUT_TAG" # full tag as provided (e.g. 1.1.3 or v2.0.0) + RAW="${INPUT_TAG#refs/tags/}" # full tag as provided (e.g. 2.0.3 or v2.0.3) else - CLEAN="$REF_NAME" # e.g. v2.0.0 + RAW="$REF_NAME" # e.g. v2.0.3 + fi + CLEAN="${RAW#v}" # strip leading v if present → 2.0.3 + if [[ ! "$CLEAN" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid release tag: $CLEAN" >&2 + exit 1 fi - CLEAN="${CLEAN#v}" # strip leading v if present → 2.0.0 or 1.1.3 + + REF_TAG="$RAW" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + REF_TAG="" + for candidate in "$RAW" "v$CLEAN" "$CLEAN"; do + if git ls-remote --exit-code --tags "$REPO_URL" "refs/tags/$candidate" >/dev/null 2>&1; then + REF_TAG="$candidate" + break + fi + done + if [ -z "$REF_TAG" ]; then + echo "No matching release tag found for input: $INPUT_TAG" >&2 + exit 1 + fi + fi + echo "clean=$CLEAN" >> "$GITHUB_OUTPUT" + echo "ref=refs/tags/$REF_TAG" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ steps.version.outputs.ref }} + persist-credentials: false + + # Guard: the release source must agree with the tag. The pre-commit + # version-check hook was removed in #46 with no automated replacement, + # which let v2.1.0 ship with version files still at 2.0.3. This is the + # release-time gate: mismatches fail here, before anything is built. + - name: 🔎 Verify source versions match release tag + env: + VERSION: ${{ steps.version.outputs.clean }} + run: | + set -euo pipefail + fail=0 + check() { + if [ "$2" != "$VERSION" ]; then + echo "::error::$1 declares version '$2' but the release tag resolves to '$VERSION'" + fail=1 + fi + } + check "socket_basics/version.py" "$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' socket_basics/version.py | head -1)" + check "socket_basics/__init__.py" "$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' socket_basics/__init__.py | head -1)" + check "pyproject.toml" "$(sed -n 's/^version = "\(.*\)"$/\1/p' pyproject.toml | head -1)" + check "action.yml image tag" "$(sed -n 's#.*docker://ghcr.io/socketdev/socket-basics:\([^"]*\)".*#\1#p' action.yml | head -1)" + if [ "$fail" -ne 0 ]; then + echo "::error::Bump the version files in the release PR, merge, then re-tag." + exit 1 + fi + echo "✅ version.py, pyproject.toml, and action.yml all agree on $VERSION" - # ── Job 2: Build → test → push ───────────────────────────────────────────── - # Delegates all Docker steps to the reusable _docker-pipeline workflow. + # ── Job 2: Build → test → push by digest (per image + arch) ──────────────── + # Each matrix entry runs the full build/smoke/integration pipeline on a + # native runner for its target arch and pushes the resulting image by digest + # to both registries. The digest is exported as an artifact for the merge job. build-test-push: - name: publish (socket-basics) + name: publish (${{ matrix.image }}, ${{ matrix.arch }}) needs: resolve-version permissions: contents: read packages: write # push images to GHCR + strategy: + fail-fast: false + matrix: + include: + - image: socket-basics + dockerfile: Dockerfile + check_set: main + arch: amd64 + runs_on: ubuntu-latest + - image: socket-basics + dockerfile: Dockerfile + check_set: main + arch: arm64 + runs_on: ubuntu-24.04-arm + - image: socket-basics-heavy + dockerfile: Dockerfile.heavy + check_set: heavy + arch: amd64 + runs_on: ubuntu-latest + - image: socket-basics-heavy + dockerfile: Dockerfile.heavy + check_set: heavy + arch: arm64 + runs_on: ubuntu-24.04-arm uses: ./.github/workflows/_docker-pipeline.yml with: - name: socket-basics - dockerfile: Dockerfile + name: ${{ matrix.image }} + dockerfile: ${{ matrix.dockerfile }} context: . - check_set: main + check_set: ${{ matrix.check_set }} + runs_on: ${{ matrix.runs_on }} + arch_label: ${{ matrix.arch }} + # All variants publish to the single socket-basics repository on each + # registry; variants are distinguished by tag suffix (e.g. -heavy), never + # by a separate repository. + push_name: socket-basics push: true - tag_push: ${{ github.ref_type == 'tag' }} version: ${{ needs.resolve-version.outputs.version }} + ref: ${{ needs.resolve-version.outputs.ref }} secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} - # ── Job 3: Create GitHub release ─────────────────────────────────────────── - # Runs once after the image is successfully pushed (not for workflow_dispatch + # ── Job 3: Merge per-arch digests into a multi-arch manifest list ────────── + # Floating major version tags (v2 → latest v2.x.y) are intentionally omitted. + # Mutable tags are structurally equivalent to :latest and inappropriate for a + # security tool. Users should pin to an exact version and use Dependabot. + merge-manifests: + name: merge-manifests (${{ matrix.variant }}) + needs: [resolve-version, build-test-push] + permissions: + contents: read + packages: write + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Both variants live in the single socket-basics repository per registry, + # distinguished by tag suffix (2.2.0 vs 2.2.0-heavy). `variant` selects + # the per-arch digest artifacts produced by build-test-push. + include: + - variant: socket-basics + tag_suffix: "" + - variant: socket-basics-heavy + tag_suffix: "-heavy" + steps: + - name: ⬇️ Download per-arch digest artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: /tmp/digests + pattern: digests-${{ matrix.variant }}__* + merge-multiple: true + + - name: 🔨 Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Login to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Login to Docker Hub + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: | + ghcr.io/socketdev/socket-basics + ${{ secrets.DOCKERHUB_USERNAME }}/socket-basics + # Disable the automatic :latest tag — metadata-action adds it by default + # for semver tag pushes. Mutable tags are inappropriate for a security tool. + # The variant suffix yields 2.2.0 for the main image, 2.2.0-heavy for heavy. + flavor: | + latest=false + suffix=${{ matrix.tag_suffix }} + tags: | + # Tag push (v2.0.0) → exact immutable version tag only. + type=semver,pattern={{version}} + # workflow_dispatch re-publish → use the version input directly + type=raw,value=${{ needs.resolve-version.outputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }} + + - name: 🧬 Create multi-arch manifest list + working-directory: /tmp/digests + env: + GHCR_IMAGE: ghcr.io/socketdev/socket-basics + DH_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/socket-basics + META_TAGS: ${{ steps.meta.outputs.tags }} + EXPECTED_ARCHES: "2" + run: | + set -euo pipefail + shopt -s nullglob + digests=(*) + if [ "${#digests[@]}" -ne "$EXPECTED_ARCHES" ]; then + echo "::error::expected $EXPECTED_ARCHES per-arch digests for ${{ matrix.variant }}, found ${#digests[@]}" + ls -la + exit 1 + fi + # Each by-digest push from the matrix step landed blobs in BOTH + # registries, so per-registry imagetools-create only writes manifests. + for image in "$GHCR_IMAGE" "$DH_IMAGE"; do + tag_args=() + while IFS= read -r tag; do + [ -z "$tag" ] && continue + case "$tag" in + "$image:"*) tag_args+=(-t "$tag") ;; + esac + done <<< "$META_TAGS" + if [ ${#tag_args[@]} -eq 0 ]; then + echo "::error::no tags resolved for $image" + exit 1 + fi + sources=() + for digest in "${digests[@]}"; do + sources+=("${image}@sha256:${digest}") + done + echo "→ creating manifest list for $image with ${#sources[@]} arch sources" + docker buildx imagetools create "${tag_args[@]}" "${sources[@]}" + done + + - name: 🔍 Inspect published manifest + env: + GHCR_IMAGE: ghcr.io/socketdev/socket-basics + DH_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/socket-basics + VERSION: ${{ needs.resolve-version.outputs.version }} + TAG_SUFFIX: ${{ matrix.tag_suffix }} + run: | + set -euo pipefail + for image in "$GHCR_IMAGE" "$DH_IMAGE"; do + ref="${image}:${VERSION}${TAG_SUFFIX}" + inspect="$(docker buildx imagetools inspect "$ref")" + echo "$inspect" + for platform in linux/amd64 linux/arm64; do + if ! grep -q "Platform:[[:space:]]*$platform" <<< "$inspect"; then + echo "::error::$ref is missing $platform" + exit 1 + fi + done + done + + # ── Job 4: Create GitHub release ─────────────────────────────────────────── + # Runs once after the manifest is published (not for workflow_dispatch # re-publishes — those don't create new releases). # Generates categorised release notes from merged PR labels (.github/release.yml). # CHANGELOG updates are intentionally human-authored in the release PR so this # workflow never needs to push commits to the protected default branch. create-release: - needs: [resolve-version, build-test-push] + needs: [resolve-version, merge-manifests] if: github.ref_type == 'tag' permissions: contents: write # create GitHub release diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 480e9db..025ae14 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -1,6 +1,6 @@ name: smoke-test -# Builds the main socket-basics image and verifies all baked-in tools respond. +# Builds socket-basics image variants and verifies all baked-in tools respond. # Calls _docker-pipeline.yml in smoke-only mode (no push to registries). on: @@ -8,12 +8,16 @@ on: branches: [main] paths: - 'Dockerfile' + - 'Dockerfile.heavy' + - 'scripts/docker-heavy-entrypoint.sh' - 'scripts/smoke-test-docker.sh' - '.github/workflows/smoke-test.yml' - '.github/workflows/_docker-pipeline.yml' pull_request: paths: - 'Dockerfile' + - 'Dockerfile.heavy' + - 'scripts/docker-heavy-entrypoint.sh' - 'scripts/smoke-test-docker.sh' - '.github/workflows/smoke-test.yml' - '.github/workflows/_docker-pipeline.yml' @@ -29,12 +33,41 @@ concurrency: cancel-in-progress: true jobs: + # Native build + smoke per arch. amd64 covers the standard runner; arm64 + # covers ubuntu-24.04-arm and Apple Silicon self-hosted runners (issue #69). + # Native runners build ~5x faster than QEMU and exercise the real binaries. smoke: - name: smoke (socket-basics) + name: smoke (${{ matrix.image }}, ${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - image: socket-basics + dockerfile: Dockerfile + check_set: main + arch: amd64 + runs_on: ubuntu-latest + - image: socket-basics + dockerfile: Dockerfile + check_set: main + arch: arm64 + runs_on: ubuntu-24.04-arm + - image: socket-basics-heavy + dockerfile: Dockerfile.heavy + check_set: heavy + arch: amd64 + runs_on: ubuntu-latest + - image: socket-basics-heavy + dockerfile: Dockerfile.heavy + check_set: heavy + arch: arm64 + runs_on: ubuntu-24.04-arm uses: ./.github/workflows/_docker-pipeline.yml with: - name: socket-basics - dockerfile: Dockerfile + name: ${{ matrix.image }} + dockerfile: ${{ matrix.dockerfile }} context: . - check_set: main + check_set: ${{ matrix.check_set }} + runs_on: ${{ matrix.runs_on }} + arch_label: ${{ matrix.arch }} push: false diff --git a/CHANGELOG.md b/CHANGELOG.md index ddc0828..2ed4a71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -## [2.1.0] - 2026-06-02 +## [2.2.0] - 2026-07-29 + +### Added +- Publish multi-arch Docker images for `linux/amd64` and `linux/arm64`. +- Add a heavy image variant (`socket-basics:-heavy` tag suffix) bundling + Socket Basics with the pinned Python Socket CLI. + +### Fixed +- Normalize manual Docker release tag inputs before checkout. +- core-tool-watch now opts into fail-closed Socket purl batch semantics + (`poll` + `alerts`), so fresh-but-unanalyzed pins surface as labeled + pending/not-found failures instead of silently dropped rows. + +## [2.1.0] - 2026-07-22 ### Added - Diff-only scan scoping now applies to SAST/OpenGrep via `changed_files` and diff --git a/Dockerfile b/Dockerfile index ff11d58..6ea5bb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,4 +84,4 @@ LABEL org.opencontainers.image.title="Socket Basics" \ ENV PATH="/socket-basics/.venv/bin:/root/.opengrep/cli/latest:/usr/local/bin:$PATH" -ENTRYPOINT ["socket-basics"] \ No newline at end of file +ENTRYPOINT ["socket-basics"] diff --git a/Dockerfile.heavy b/Dockerfile.heavy new file mode 100644 index 0000000..9a180f9 --- /dev/null +++ b/Dockerfile.heavy @@ -0,0 +1,68 @@ +# Heavy POC image: socket-basics plus a pinned stable Python Socket CLI. +ARG PYTHON_VERSION=3.12 +ARG TRUFFLEHOG_VERSION=3.93.8 +ARG TRIVY_VERSION=0.69.3 +ARG UV_VERSION=0.10.11 +ARG OPENGREP_VERSION=v1.16.5 +ARG SOCKET_CLI_VERSION=2.5.0 + +# FROM aquasec/trivy:${TRIVY_VERSION} AS trivy +FROM trufflesecurity/trufflehog:${TRUFFLEHOG_VERSION} AS trufflehog +FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv + +FROM python:${PYTHON_VERSION}-slim AS opengrep-installer +ARG OPENGREP_VERSION +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates bash +RUN curl -fsSL https://raw.githubusercontent.com/opengrep/opengrep/main/install.sh \ + | bash -s -- -v "${OPENGREP_VERSION}" + +FROM python:${PYTHON_VERSION}-slim AS runtime + +WORKDIR /socket-basics + +COPY --from=uv /uv /uvx /bin/ +COPY --from=trufflehog /usr/bin/trufflehog /usr/local/bin/trufflehog +COPY --from=opengrep-installer /root/.opengrep /root/.opengrep + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + curl git wget ca-certificates +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs +RUN --mount=type=cache,target=/root/.npm \ + npm install -g socket + +COPY socket_basics /socket-basics/socket_basics +COPY pyproject.toml README.md LICENSE uv.lock /socket-basics/ + +ENV UV_LINK_MODE=copy +ARG SOCKET_CLI_VERSION +RUN --mount=type=cache,target=/root/.cache/uv \ + pip install -e . \ + && uv sync --frozen --no-dev \ + && pip install --no-cache-dir "socketsecurity==${SOCKET_CLI_VERSION}" + +COPY scripts/docker-heavy-entrypoint.sh /usr/local/bin/docker-heavy-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-heavy-entrypoint.sh + +ARG SOCKET_BASICS_VERSION=dev +ARG VCS_REF=unknown +ARG BUILD_DATE=unknown +ARG TRUFFLEHOG_VERSION +ARG OPENGREP_VERSION +LABEL org.opencontainers.image.title="Socket Basics Heavy" \ + org.opencontainers.image.source="https://github.com/SocketDev/socket-basics" \ + org.opencontainers.image.version="${SOCKET_BASICS_VERSION}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.revision="${VCS_REF}" \ + com.socket.cli-version="${SOCKET_CLI_VERSION}" \ + com.socket.trufflehog-version="${TRUFFLEHOG_VERSION}" \ + com.socket.opengrep-version="${OPENGREP_VERSION}" + +ENV PATH="/socket-basics/.venv/bin:/root/.opengrep/cli/latest:/usr/local/bin:$PATH" + +ENTRYPOINT ["/usr/local/bin/docker-heavy-entrypoint.sh"] diff --git a/action.yml b/action.yml index 867c3f7..42d6660 100644 --- a/action.yml +++ b/action.yml @@ -4,7 +4,7 @@ author: "Socket" runs: using: "docker" - image: "docker://ghcr.io/socketdev/socket-basics:2.0.3" + image: "docker://ghcr.io/socketdev/socket-basics:2.2.0" env: # Core GitHub variables (these are automatically available, but we explicitly pass GITHUB_TOKEN) GITHUB_TOKEN: ${{ inputs.github_token }} diff --git a/pyproject.toml b/pyproject.toml index fc8be92..9a28003 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "socket_basics" -version = "2.0.3" +version = "2.2.0" description = "Socket Basics with integrated SAST, secret scanning, and container analysis" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/check_core_tools.py b/scripts/check_core_tools.py index 733e30d..e53b7b1 100644 --- a/scripts/check_core_tools.py +++ b/scripts/check_core_tools.py @@ -32,13 +32,16 @@ dependency-review.yml). Exit code is 0 unless --fail-on-malware is set AND a PINNED version trips the -(deliberately strict) thresholds: any alert type in MALWARE_ALERT_TYPES -- a -curated list that goes beyond outright malware to include strong risk signals -like install scripts, obfuscation, and telemetry -- OR any alert of high or -critical severity. With a token present, a Socket scoring error -- or a -covered pinned coordinate missing from the returned batch -- also fails -(fail-closed: unverified pins must not ship; OpenGrep's documented pkg:github -coverage gap is the one exemption). Drift alone never fails the run, +thresholds: any alert type in MALWARE_ALERT_TYPES -- a curated list of +compromise and compromise-adjacent signals -- OR any alert of critical +severity. With a token present, a Socket scoring error also fails, as +does a covered pinned coordinate that comes back still pending analysis +(synthetic pendingScan row), unresolvable (synthetic notFound row), or missing +from the returned batch entirely (fail-closed: unverified pins must not ship; +OpenGrep's documented pkg:github coverage gap is the one exemption). The batch +call opts into poll=true + alerts=true so fresh-but-unanalyzed versions +surface as labeled pendingScan rows instead of being silently omitted by the +endpoint's fail-open default. Drift alone never fails the run, and the discovered *latest* version is scored for reporting only; both are surfaced via the JSON report and the `drift`/`malware`/`critical` GitHub outputs so the workflow decides what to do. @@ -62,21 +65,19 @@ DOCKERFILES = [REPO_ROOT / "Dockerfile", REPO_ROOT / "app_tests" / "Dockerfile"] UV_LOCK = REPO_ROOT / "uv.lock" -# Alert types treated as fail-worthy on a pinned version. Deliberately broader -# than literal malware: alongside outright compromise (malware, trojan, -# backdoor) it includes strong risk signals (obfuscation, install scripts, -# shell access, telemetry, typosquat hints) -- for the four core tools we bake -# into the image, any of these deserves a hard stop and a human look, at the -# cost of occasional false positives. Trim this set rather than disabling -# --fail-on-malware if it proves too noisy. +# Alert types treated as fail-worthy on a pinned version: outright compromise +# signals plus typosquat/fake-popularity hints and compromise-adjacent +# behaviors (install scripts, telemetry). Calibrated against real batch data +# once alerts=true started returning the full alert set (run 30504424787): +# capability signals (shellAccess -- present on ALL four tools; they spawn +# subprocesses by design) and heuristic/static signals (gptMalware, +# gptSecurity, obfuscatedFile -- a SAST engine ships malicious-looking test +# fixtures on purpose) are informational there, not compromise evidence, and +# were removed. Trim further rather than disabling --fail-on-malware if new +# noise appears. MALWARE_ALERT_TYPES = { "malware", - "gptMalware", - "gptSecurity", "didYouMean", - "obfuscatedFile", - "obfuscatedRequire", - "shellAccess", "suspiciousStarActivity", "cryptoMiner", "installScript", @@ -84,8 +85,28 @@ "trojan", "backdoor", } -# Severities that count as fail-worthy: includes "high", not just "critical". -CRITICAL_SEVERITIES = {"critical", "high"} +# Severities that count as fail-worthy. "high" was included while the batch +# response carried no alert data (the pre-alerts=true fail-open default made +# this gate dead code); the full alert set carries high-severity heuristic and +# cve rows on perfectly healthy tools (gptMalware/obfuscatedFile on the +# OpenGrep repo artifact, cve on Trivy), so the hard gate is critical-only. +# High-severity findings still land in the report for human review. +CRITICAL_SEVERITIES = {"critical"} + +# Synthetic batch-status alert types the purl endpoints emit when called with +# alerts=true (added upstream ~2026-04, depscan #18990). They mark inputs whose +# analysis is incomplete (pendingScan) or whose coordinate could not be +# resolved (notFound) -- without alerts=true such inputs are SILENTLY OMITTED +# from the response (the endpoint's documented fail-open default), which is +# what made fresh pins like pkg:pypi/socketdev@3.3.0 trip the unverified-pin +# guard with a misleading "batch dropped rows" message. These are batch-status +# markers, not package risk signals: they must never be classified through +# MALWARE_ALERT_TYPES / CRITICAL_SEVERITIES regardless of the severity label +# they carry. +SYNTHETIC_STATUS_ALERTS = { + "pendingScan": "pending", + "notFound": "not_found", +} @dataclass @@ -265,7 +286,9 @@ def analyze_purls(purls: list[str], token: str) -> dict[str, dict[str, Any]]: from socketdev import socketdev # imported lazily; only needed with a token - client = socketdev(token=token, timeout=60) + # Client timeout must exceed the server-side poll bound (timeoutSec=120 + # below), or the HTTP call would abort before the server finishes waiting. + client = socketdev(token=token, timeout=180) # Prefer the org-scoped purl endpoint. socketdev >= 3.1 deprecates the # legacy POST /v0/purl (used when org_slug is absent) in favor of @@ -282,6 +305,7 @@ def analyze_purls(purls: list[str], token: str) -> dict[str, dict[str, Any]]: slug = next(iter(orgs.values())).get("slug") if len(orgs) == 1 else None if slug: kwargs["org_slug"] = slug + print(f" using org-scoped purl endpoint (org={slug})") else: print( f" ! org slug not resolvable ({len(orgs)} orgs on token); using legacy purl endpoint", @@ -289,7 +313,21 @@ def analyze_purls(purls: list[str], token: str) -> dict[str, dict[str, Any]]: ) components = [{"purl": p} for p in purls] - results = client.purl.post(license="false", components=components, **kwargs) or [] + # The batch purl endpoints default to fail-open: inputs whose analysis is + # pending or unresolvable are silently omitted from the response unless the + # caller opts in. Opt in to fail-closed semantics: poll=true waits (bounded + # by timeoutSec; the server may cap it) for pending analysis, and + # alerts=true materializes still-unresolved inputs as synthetic + # pendingScan/notFound rows instead of dropping them. The SDK passes these + # extra kwargs through as query params (verified on 3.0.29 and 3.3.0). + results = client.purl.post( + license="false", + components=components, + poll="true", + timeoutSec="120", + alerts="true", + **kwargs, + ) or [] if not results: raise RuntimeError( f"Socket purl API returned no results for {len(purls)} PURLs " @@ -304,9 +342,17 @@ def analyze_purls(purls: list[str], token: str) -> dict[str, dict[str, Any]]: norm_alerts = [] malware = [] critical = [] + status = None for a in alerts: a_type = a.get("type", "") a_sev = (a.get("severity") or "").lower() + # Synthetic batch-status markers (from alerts=true) are handled + # before severity classification: whatever severity/action labels + # they carry after org-policy application, they describe the batch + # row, not the package. + if a_type in SYNTHETIC_STATUS_ALERTS: + status = status or SYNTHETIC_STATUS_ALERTS[a_type] + continue norm_alerts.append({"type": a_type, "severity": a_sev}) if a_type in MALWARE_ALERT_TYPES: malware.append(a_type) @@ -317,6 +363,7 @@ def analyze_purls(purls: list[str], token: str) -> dict[str, dict[str, Any]]: "version": item.get("version"), "type": item.get("type"), "score": item.get("score"), + "status": status, "alerts": norm_alerts, "malware": sorted(set(malware)), "critical": sorted(set(critical)), @@ -376,6 +423,10 @@ def verdict(version: Optional[str]) -> str: suffix = f" _(via {t.proxy_label})_" if not a: return "no data" + if a.get("status") == "pending": + return "⏳ analysis pending upstream" + suffix + if a.get("status") == "not_found": + return "❓ coordinate not resolvable" + suffix if a.get("malware"): return "🚨 MALWARE: " + ", ".join(a["malware"]) + suffix if a.get("critical"): @@ -469,6 +520,8 @@ def main() -> int: any_malware = False any_critical = False unverified: list[str] = [] + pending: list[str] = [] + not_found: list[str] = [] findings: list[dict[str, Any]] = [] for t in tools: drift = bool(t.latest and any(_strip_v(p) != _strip_v(t.latest) for p in t.pinned)) @@ -490,6 +543,16 @@ def main() -> int: any_malware = True if a.get("critical"): any_critical = True + # Synthetic status on a covered pin: the batch answered, but + # not with analysis. Same fail-closed posture as unverified, + # tracked separately so the error names the actual condition. + # Coverage-gap tools (OpenGrep's pkg:github) are exempt: with + # alerts=true their known-uncovered pin now returns a notFound + # row instead of being silently omitted. + if t.socket_coverage and a.get("status") == "pending": + pending.append(f"{t.key} {t.purl(v)}") + elif t.socket_coverage and a.get("status") == "not_found": + not_found.append(f"{t.key} {t.purl(v)}") elif token_present and not scoring_error and t.socket_coverage: # Scoring "succeeded" but this pinned coordinate has no row -- # a partial batch or a purl/echo mismatch. The guard's job is @@ -527,6 +590,8 @@ def main() -> int: "token_present": token_present, "scoring_error": scoring_error, "unverified": unverified, + "pending": pending, + "not_found": not_found, "findings": findings, }, indent=2, @@ -556,8 +621,32 @@ def main() -> int: file=sys.stderr, ) return 1 + # Fail closed: Socket knows the coordinate but analysis was still + # running when the bounded poll expired. Distinct from a dropped row: + # this is upstream latency, not an API anomaly. + if pending: + print( + "::error::Socket analysis still pending after the bounded poll for pinned " + "coordinate(s): " + "; ".join(pending) + + ". Failing closed -- re-run later, or investigate Socket ingestion if it persists.", + file=sys.stderr, + ) + return 1 + # Fail closed: Socket could not resolve the coordinate at all. For a + # published package version this is a registry/ingestion bug with a + # one-line repro -- hand it to the API team. + if not_found: + print( + "::error::Socket cannot resolve pinned coordinate(s): " + + "; ".join(not_found) + + ". Failing closed -- likely a Socket registry/ingestion gap; report it upstream.", + file=sys.stderr, + ) + return 1 # Fail closed: scoring returned rows, but some covered pinned - # coordinate has none -- a partial batch is not a clean bill. + # coordinate has none -- with poll+alerts requested this should no + # longer happen for merely-fresh versions, so a missing row is a + # genuine anomaly (purl/echo mismatch or batch drop). if unverified: print( "::error::Socket scoring returned no analysis for pinned coordinate(s): " diff --git a/scripts/docker-heavy-entrypoint.sh b/scripts/docker-heavy-entrypoint.sh new file mode 100644 index 0000000..e7422aa --- /dev/null +++ b/scripts/docker-heavy-entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +if [ "$#" -eq 0 ]; then + exec socket-basics -h +fi + +case "$1" in + socket-basics) + shift + exec socket-basics "$@" + ;; + socketcli) + shift + exec socketcli "$@" + ;; + *) + exec socket-basics "$@" + ;; +esac diff --git a/scripts/integration-test-docker.sh b/scripts/integration-test-docker.sh index 748242d..77545ff 100755 --- a/scripts/integration-test-docker.sh +++ b/scripts/integration-test-docker.sh @@ -8,7 +8,7 @@ # # Usage: # ./scripts/integration-test-docker.sh [--image-tag TAG] -# ./scripts/integration-test-docker.sh --image-tag socket-basics:1.1.3 +# ./scripts/integration-test-docker.sh --image-tag socket-basics:2.0.3 set -euo pipefail diff --git a/scripts/prep_release.py b/scripts/prep_release.py new file mode 100755 index 0000000..8208f53 --- /dev/null +++ b/scripts/prep_release.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +prep_release.py — Prepare the final release-prep PR for a new version. + +Feature PRs never touch version files; they only add CHANGELOG entries under +[Unreleased]. When a release batch is complete, run this once on a fresh +branch: it bumps every version-bearing file and stamps the [Unreleased] +changelog section, so the release PR is a mechanical five-file diff. Tag the +release PR's merge commit and the publish workflow's version gate passes by +construction. + +Files updated: + pyproject.toml [project] version (canonical source) + socket_basics/version.py derived via sync_release_version.py + socket_basics/__init__.py derived via sync_release_version.py + action.yml derived via sync_release_version.py + uv.lock project entry (via `uv lock`) + CHANGELOG.md [Unreleased] -> [X.Y.Z] - YYYY-MM-DD + +Usage: + python scripts/prep_release.py --version 2.2.0 + python scripts/prep_release.py --version 2.2.0 --date 2026-07-29 + python scripts/prep_release.py --version 2.2.0 --dry-run +""" +from __future__ import annotations + +import argparse +import datetime +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).parent.parent +PYPROJECT = ROOT / "pyproject.toml" +CHANGELOG = ROOT / "CHANGELOG.md" + +SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") + +# pyproject.toml is the canonical version source; version.py, __init__.py, and +# action.yml are derived from it by scripts/sync_release_version.py. +PYPROJECT_PATTERN = re.compile(r'^version = "(\d+\.\d+\.\d+)"$', re.M) + + +def _bump_file(path: Path, pattern: re.Pattern[str], replacement: str, version: str) -> tuple[str, str]: + """Return (old_version, new_content) without writing.""" + content = path.read_text() + match = pattern.search(content) + if not match: + sys.exit(f"error: no version pattern found in {path.relative_to(ROOT)}") + old = match.group(match.lastindex or 0) if match.lastindex else match.group(1) + new_content, count = pattern.subn(replacement.format(v=version), content, count=1) + if count != 1: + sys.exit(f"error: expected exactly one version in {path.relative_to(ROOT)}, replaced {count}") + return old, new_content + + +def _stamp_changelog(version: str, date: str) -> str: + """Return the stamped CHANGELOG content without writing.""" + content = CHANGELOG.read_text() + + if f"## [{version}]" in content: + sys.exit(f"error: CHANGELOG.md already has a [{version}] section") + + match = re.search(r"^## \[Unreleased\]\n(.*?)(?=^## \[)", content, re.M | re.S) + if not match: + sys.exit("error: could not find an [Unreleased] section followed by a release section") + + body = match.group(1).strip("\n") + if not body.strip(): + sys.exit("error: [Unreleased] is empty — nothing to release. " + "Feature PRs should add their entries there before release prep.") + + stamped = f"## [Unreleased]\n\n## [{version}] - {date}\n\n{body}\n\n" + return content[:match.start()] + stamped + content[match.end():] + + +def _refresh_lock(dry_run: bool) -> None: + if dry_run: + print("dry-run: skipping `uv lock`") + return + try: + subprocess.run(["uv", "lock"], cwd=ROOT, check=True, capture_output=True, text=True) + except FileNotFoundError: + sys.exit("error: `uv` not found — install uv or run `uv lock` manually before committing") + except subprocess.CalledProcessError as exc: + sys.exit(f"error: `uv lock` failed:\n{exc.stderr}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Prepare version bumps and changelog for a release PR.") + parser.add_argument("--version", required=True, help="Release version without v prefix, e.g. 2.2.0") + parser.add_argument("--date", default=datetime.date.today().isoformat(), + help="Release date for the changelog section (default: today)") + parser.add_argument("--dry-run", action="store_true", help="Report changes without writing") + args = parser.parse_args() + + if not SEMVER_RE.match(args.version): + sys.exit(f"error: version must be X.Y.Z (got {args.version!r})") + + tags = subprocess.run(["git", "tag", "--list", f"v{args.version}", args.version], + cwd=ROOT, capture_output=True, text=True).stdout.split() + if tags: + sys.exit(f"error: tag for {args.version} already exists: {', '.join(tags)}") + + # Validate and render every change first; only write once all succeed, + # so a failure never leaves a half-modified tree. + old, pyproject_content = _bump_file(PYPROJECT, PYPROJECT_PATTERN, 'version = "{v}"', args.version) + changelog_content = _stamp_changelog(args.version, args.date) + + if not args.dry_run: + PYPROJECT.write_text(pyproject_content) + print(f"pyproject.toml: {old} -> {args.version}") + + if args.dry_run: + print("dry-run: skipping sync_release_version.py --write") + else: + subprocess.run([sys.executable, str(ROOT / "scripts" / "sync_release_version.py"), "--write"], + cwd=ROOT, check=True) + + if not args.dry_run: + CHANGELOG.write_text(changelog_content) + print(f"CHANGELOG.md: [Unreleased] -> [{args.version}] - {args.date}") + + _refresh_lock(args.dry_run) + if not args.dry_run: + print("uv.lock: refreshed") + + print("\nNext steps: commit these changes on a release branch, open the release PR,") + print(f"merge it last, then tag the merge commit as v{args.version} to trigger publish.") + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke-test-docker.sh b/scripts/smoke-test-docker.sh index 2962951..14c2243 100644 --- a/scripts/smoke-test-docker.sh +++ b/scripts/smoke-test-docker.sh @@ -8,6 +8,8 @@ APP_TESTS_IMAGE_TAG="${APP_TESTS_IMAGE_TAG:-socket-basics-app-tests:smoke-test}" RUN_APP_TESTS=false SKIP_BUILD=false CHECK_SET="main" +DOCKERFILE="Dockerfile" +DOCKERFILE_SET=false BUILD_PROGRESS="${SMOKE_TEST_BUILD_PROGRESS:-}" MAIN_TOOLS=( @@ -23,6 +25,14 @@ APP_TESTS_TOOLS=( "command -v socket" ) +HEAVY_TOOLS=( + "socket-basics -h" + "socketcli --help" + "command -v socket" + "trufflehog --version" + "opengrep --version" +) + # TEMPORARY: trivy is being removed to assess impact. These checks FAIL if the # tool is still present in the image — ensures removal is complete. MUST_NOT_EXIST_TOOLS=( @@ -30,9 +40,10 @@ MUST_NOT_EXIST_TOOLS=( ) usage() { - echo "Usage: $0 [--image-tag TAG] [--app-tests] [--skip-build] [--check-set main|app-tests] [--build-progress MODE]" + echo "Usage: $0 [--image-tag TAG] [--app-tests] [--skip-build] [--check-set main|app-tests|heavy] [--dockerfile FILE] [--build-progress MODE]" echo " --skip-build: skip docker build; verify tools in a pre-built image" - echo " --check-set: which tool set to verify: main (default) or app-tests" + echo " --check-set: which tool set to verify: main (default), app-tests, or heavy" + echo " --dockerfile: Dockerfile to build in non-skip mode (default: Dockerfile)" echo " --build-progress: auto|plain|tty (default: auto locally, plain in CI)" } @@ -49,6 +60,10 @@ while [[ $# -gt 0 ]]; do [[ $# -lt 2 ]] && { echo "Error: --check-set requires a value"; exit 1; } CHECK_SET="$2"; shift 2 ;; + --dockerfile) + [[ $# -lt 2 ]] && { echo "Error: --dockerfile requires a value"; exit 1; } + DOCKERFILE="$2"; DOCKERFILE_SET=true; shift 2 + ;; --build-progress) [[ $# -lt 2 ]] && { echo "Error: --build-progress requires a value"; exit 1; } BUILD_PROGRESS="$2"; shift 2 @@ -58,10 +73,14 @@ while [[ $# -gt 0 ]]; do done case "$CHECK_SET" in - main|app-tests) ;; - *) echo "Error: invalid --check-set '$CHECK_SET' (must be 'main' or 'app-tests')"; exit 1 ;; + main|app-tests|heavy) ;; + *) echo "Error: invalid --check-set '$CHECK_SET' (must be 'main', 'app-tests', or 'heavy')"; exit 1 ;; esac +if [[ "$CHECK_SET" == "heavy" && "$DOCKERFILE_SET" == "false" ]]; then + DOCKERFILE="Dockerfile.heavy" +fi + if [[ -z "$BUILD_PROGRESS" ]]; then if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then BUILD_PROGRESS="plain" @@ -133,6 +152,8 @@ if $SKIP_BUILD; then echo "Check set: $CHECK_SET" if [[ "$CHECK_SET" == "app-tests" ]]; then run_checks "$IMAGE_TAG" "${APP_TESTS_TOOLS[@]}" + elif [[ "$CHECK_SET" == "heavy" ]]; then + run_checks "$IMAGE_TAG" "${HEAVY_TOOLS[@]}" else run_checks "$IMAGE_TAG" "${MAIN_TOOLS[@]}" fi @@ -141,15 +162,20 @@ else # ── Normal mode: build then verify ──────────────────────────────────────── echo "==> Build main image" echo "Image: $IMAGE_TAG" + echo "Dockerfile: $DOCKERFILE" echo "Docker build progress mode: $BUILD_PROGRESS" build_args_for_tag "$IMAGE_TAG" main_build_start="$(date +%s)" - docker build "${BUILD_ARGS[@]}" . + docker build -f "$DOCKERFILE" "${BUILD_ARGS[@]}" . main_build_end="$(date +%s)" echo "Main image build completed in $((main_build_end - main_build_start))s" echo "==> Verify tools in main image" - run_checks "$IMAGE_TAG" "${MAIN_TOOLS[@]}" + if [[ "$CHECK_SET" == "heavy" ]]; then + run_checks "$IMAGE_TAG" "${HEAVY_TOOLS[@]}" + else + run_checks "$IMAGE_TAG" "${MAIN_TOOLS[@]}" + fi run_must_not_exist_checks "$IMAGE_TAG" "${MUST_NOT_EXIST_TOOLS[@]}" if $RUN_APP_TESTS; then diff --git a/scripts/update_changelog.py b/scripts/update_changelog.py index 373f4cc..1787c1c 100755 --- a/scripts/update_changelog.py +++ b/scripts/update_changelog.py @@ -85,11 +85,11 @@ def _update_links(content: str, version: str, prev_tag: str) -> str: Update the comparison links block at the bottom of the changelog. Before: - [Unreleased]: .../compare/1.1.3...HEAD + [Unreleased]: .../compare/v2.0.3...HEAD - After publishing v2.0.1: - [Unreleased]: .../compare/v2.0.1...HEAD - [2.0.1]: .../compare/v2.0.0...v2.0.1 + After publishing v2.0.4: + [Unreleased]: .../compare/v2.0.4...HEAD + [2.0.4]: .../compare/v2.0.3...v2.0.4 """ new_tag = _tag(version) diff --git a/socket_basics/__init__.py b/socket_basics/__init__.py index 6dd634c..b00b4f6 100644 --- a/socket_basics/__init__.py +++ b/socket_basics/__init__.py @@ -12,7 +12,7 @@ from .socket_basics import SecurityScanner, main from .core.config import load_config_from_env, Config -__version__ = "2.0.3" +__version__ = "2.2.0" __author__ = "Socket.dev" __email__ = "support@socket.dev" diff --git a/socket_basics/version.py b/socket_basics/version.py index 5fa9130..8a124bf 100644 --- a/socket_basics/version.py +++ b/socket_basics/version.py @@ -1 +1 @@ -__version__ = "2.0.3" +__version__ = "2.2.0" diff --git a/uv.lock b/uv.lock index fe586c9..b03fc51 100644 --- a/uv.lock +++ b/uv.lock @@ -672,7 +672,7 @@ wheels = [ [[package]] name = "socket-basics" -version = "2.0.3" +version = "2.2.0" source = { editable = "." } dependencies = [ { name = "jsonschema" },