name: Prerelease Docker Image # Build the canonical Docker image for a release. In the build-once-promote # pipeline, this workflow IS the build — docker-publish.yml only retags the # manifest produced here. Cosign signing, SBOM attestation, and SLSA # provenance are attached here once, keyed by manifest digest, so they're # discoverable from any tag (including the release tags later created by # imagetools create). # # Triggered exclusively via workflow_call from release.yml (after security # gates pass). No workflow_dispatch — security and gate semantics are # enforced by the caller. The build runs automatically; the only human # approval in the release flow is the `release` env on this workflow's # jobs + publish-docker + trigger-pypi + create-release in release.yml # (gates the actual publish, not the canonical build). on: workflow_call: inputs: version: description: "Bare semver, e.g. '1.6.9' (no leading 'v')" type: string required: true short_sha: description: "First 7 chars of commit SHA (used in the prerelease tag)" type: string required: true secrets: # Explicit secrets contract instead of `secrets: inherit` on the # caller side — narrower blast radius if a future caller misuses # this reusable workflow. DOCKER_USERNAME: required: true description: "Docker Hub username for image push" DOCKER_PASSWORD: required: true description: "Docker Hub PAT (Read+Write+Delete scopes)" outputs: manifest_digest: description: "sha256:... digest of the multi-arch prerelease manifest. Used by docker-publish.yml to verify retag preserves the digest." value: ${{ jobs.create-manifest.outputs.digest }} permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions # No approval gate at the build step — the build runs automatically once # security gates and CI gates in release.yml pass. The only meaningful # human decision in the release flow is "should this signed, attested, # tested image become the official release?" — gated by the `release` # environment on this workflow's jobs + `publish-docker` + `trigger-pypi` # + `create-release` in release.yml. The maintainer can pull # `:prerelease-v-` and smoke-test between build completion # and approving the release env. jobs: build-amd64: name: Build AMD64 Prerelease Image runs-on: ubuntu-latest environment: release permissions: contents: read steps: - name: Harden Runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Check out the repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false fetch-depth: 0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to Docker Hub uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push AMD64 image id: build uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/amd64 push: true tags: ${{ secrets.DOCKER_USERNAME }}/local-deep-research:prerelease-v${{ inputs.version }}-${{ inputs.short_sha }}-amd64 cache-from: type=gha,scope=linux-amd64 cache-to: type=gha,mode=max,scope=linux-amd64 build-arm64: name: Build ARM64 Prerelease Image runs-on: ubuntu-24.04-arm environment: release permissions: contents: read steps: - name: Harden Runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Check out the repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false fetch-depth: 0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to Docker Hub uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push ARM64 image id: build uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/arm64 push: true tags: ${{ secrets.DOCKER_USERNAME }}/local-deep-research:prerelease-v${{ inputs.version }}-${{ inputs.short_sha }}-arm64 cache-from: type=gha,scope=linux-arm64 cache-to: type=gha,mode=max,scope=linux-arm64 security-scan: name: Security Scan runs-on: ubuntu-latest environment: release permissions: contents: read steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Check out the repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false fetch-depth: 0 - name: Free disk space run: | sudo rm -rf /usr/share/dotnet || true sudo rm -rf /usr/local/lib/android || true sudo rm -rf /opt/ghc || true sudo rm -rf /opt/hostedtoolcache/CodeQL || true sudo docker image prune --all --force || true df -h - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Build Docker image for security scan uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/amd64 push: false load: true tags: local-deep-research:security-scan cache-from: type=gha,scope=linux-amd64 cache-to: type=gha,mode=max,scope=linux-amd64 # Generate Trivy SARIF for archival as a workflow artifact (all severities, never fails). # Severity-gating happens in the next step. - name: Generate Trivy SARIF report uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: local-deep-research:security-scan format: 'sarif' output: 'trivy-prerelease-scan.sarif' ignore-unfixed: true exit-code: '0' version: 'v0.69.2' # Separate scan that fails build only on fixable HIGH/CRITICAL vulnerabilities - name: Check for fixable HIGH/CRITICAL vulnerabilities uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: local-deep-research:security-scan severity: 'CRITICAL,HIGH' ignore-unfixed: true trivyignores: '.trivyignore' exit-code: '1' version: 'v0.69.2' - name: Upload Trivy scan results uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: trivy-prerelease-scan path: trivy-prerelease-scan.sarif retention-days: 7 create-manifest: name: Create Multi-Platform Prerelease Manifest needs: [build-amd64, build-arm64, security-scan] runs-on: ubuntu-latest environment: release permissions: contents: read id-token: write # Required for cosign keyless OIDC signing # No `packages: write` — Docker Hub auth uses DOCKER_PASSWORD secret, # not GITHUB_TOKEN. `packages: write` only matters for ghcr.io pushes. outputs: digest: ${{ steps.capture-digest.outputs.digest }} steps: - name: Harden Runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Check out the repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false fetch-depth: 0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to Docker Hub uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Install Cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: # Pin to cosign v2.x — see release.yml for rationale (v3 enables # --new-bundle-format by default which changes the on-wire format # and breaks downstream verifiers still on v2). cosign-release: 'v2.6.3' - name: Install Syft uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - name: Create and push multi-platform manifest env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} VERSION: ${{ inputs.version }} SHORT_SHA: ${{ inputs.short_sha }} run: | set -euo pipefail TAG="prerelease-v${VERSION}-${SHORT_SHA}" echo "Creating manifest for: ${DOCKER_USERNAME}/local-deep-research:${TAG}" docker buildx imagetools create -t "${DOCKER_USERNAME}/local-deep-research:${TAG}" \ "${DOCKER_USERNAME}/local-deep-research:${TAG}-amd64" \ "${DOCKER_USERNAME}/local-deep-research:${TAG}-arm64" echo "Manifest created successfully" # Floating tag: re-point :prerelease at the manifest just created so # testers can pin compose to `:prerelease` and pull the latest RC via # `docker compose pull` without editing the tag each cycle. The # versioned tag above remains for reproducibility (and is what # docker-publish.yml retags by digest into :1.6.9 / :1.6 / :latest). echo "Updating floating tag: ${DOCKER_USERNAME}/local-deep-research:prerelease" docker buildx imagetools create -t "${DOCKER_USERNAME}/local-deep-research:prerelease" \ "${DOCKER_USERNAME}/local-deep-research:${TAG}" echo "Floating :prerelease tag updated" - name: Capture manifest digest id: capture-digest env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} VERSION: ${{ inputs.version }} SHORT_SHA: ${{ inputs.short_sha }} run: | set -euo pipefail IMAGE_REF="${DOCKER_USERNAME}/local-deep-research:prerelease-v${VERSION}-${SHORT_SHA}" # Same form as the existing docker-publish.yml inspector — avoids jq. DIGEST=$(docker buildx imagetools inspect "$IMAGE_REF" --format '{{json .Manifest.Digest}}' | tr -d '"') if [[ -z "$DIGEST" || "$DIGEST" != sha256:* ]]; then echo "::error::Failed to capture manifest digest (got '${DIGEST}')" exit 1 fi echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" echo "Manifest digest: ${DIGEST}" - name: Sign manifest with Cosign env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DIGEST: ${{ steps.capture-digest.outputs.digest }} run: | set -euo pipefail # Sign by digest — signature artifact lands at sha256-.sig # in the same repo, discoverable from ANY tag pointing at the same # digest (including release tags created later by docker-publish.yml's # imagetools-create retag). IMAGE_REF="${DOCKER_USERNAME}/local-deep-research@${DIGEST}" echo "Signing image by digest: $IMAGE_REF" cosign sign --yes "$IMAGE_REF" # Brief sleep to allow registry to propagate signature sleep 5 - name: Generate SLSA provenance attestation env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DIGEST: ${{ steps.capture-digest.outputs.digest }} run: | set -euo pipefail IMAGE_REF="${DOCKER_USERNAME}/local-deep-research@${DIGEST}" # entryPoint is the TOP-LEVEL caller (release.yml), not this # reusable workflow. Per SLSA GHA buildtype v1 and the canonical # slsa-github-generator, reusable workflows are explicitly NOT # entryPoints. github.run_id / github.repository / github.sha all # resolve to the caller's run context inside a reusable workflow. # builder.id pins the workflow that actually defines the build # steps — the trust root a verifier policy can pin against. We # compose it from `github.repository` and a hardcoded path to # THIS workflow file, with `github.ref` for the ref portion. # Rationale: inside a workflow_call callee, the `github` context # is scoped to the CALLER, so `github.workflow_ref` would point # at release.yml (the wrong builder). The `job` context has no # `workflow_ref` property either (only check_run_id, container, # services, status — actionlint confirms). For a local-path # reusable workflow (`uses: ./.github/workflows/...`), the # callee's ref equals the caller's `github.ref`, so composing # the path manually gives the correct # `//.github/workflows/prerelease-docker.yml@` # format that matches the Fulcio cert SAN. Cosign and # slsa-verifier both anchor on the cert anyway, so this fix is # about correctness for raw-JSON policy engines / audit tools # that read builder.id directly. # # completeness.* are FALSE because we don't capture invocation # parameters or environment, and the build does network I/O for # apt/pip/npm. Honest emptiness > false claims of completeness. # # buildInvocationId includes run_attempt so re-runs are # distinguishable in audit logs. # # Uses auto-injected GITHUB_* shell env vars (GITHUB_REPOSITORY, # GITHUB_REF, GITHUB_SHA, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT) instead # of github-context template expansion to satisfy zizmor's # template-injection check. The values are semantically identical # inside a workflow_call callee (both scope to the caller's context), # but shell-var expansion happens at runtime in a confined string # context, eliminating any theoretical injection surface from a # ref/branch name containing shell metacharacters. cat > provenance.json < "${SBOM_FILE}" # --replace prevents accumulation when a re-run lands on # the same digest (e.g. "Re-run failed jobs" after a flake). # Per cosign source pkg/cosign/remote/remote.go, --replace # is per-predicate-type, so it doesn't disturb the SLSA # attestation already on the manifest list digest. cosign attest --yes --replace \ --predicate "${SBOM_FILE}" --type spdxjson "${PER_ARCH_REF}" done - name: Upload SBOMs artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: sbom # Per-arch SBOMs only — `sbom-amd64.spdx.json`, `sbom-arm64.spdx.json`, # one per platform in the manifest list. No manifest-level SBOM is # produced (would be host-arch-only and misleading for non-amd64 # consumers). path: sbom-*.spdx.json retention-days: 90 - name: Summary env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} VERSION: ${{ inputs.version }} SHORT_SHA: ${{ inputs.short_sha }} DIGEST: ${{ steps.capture-digest.outputs.digest }} run: | TAG="prerelease-v${VERSION}-${SHORT_SHA}" { echo "## Prerelease Docker Image" echo "" echo "**Versioned tag:** \`${TAG}\`" echo "**Floating tag:** \`prerelease\` (now points at this build)" echo "" echo "**Digest:** \`${DIGEST}\`" echo "" echo '```' echo "docker pull ${DOCKER_USERNAME}/local-deep-research:${TAG}" echo "docker pull ${DOCKER_USERNAME}/local-deep-research:prerelease" echo '```' echo "" echo "Signed and attested. After release approval, docker-publish.yml" echo "will retag this exact digest as \`:${VERSION}\`, \`:major.minor\`," echo "and \`:latest\`." } >> "$GITHUB_STEP_SUMMARY"