name: Publish Docker image # SECURITY: Only invoked as a reusable workflow (`workflow_call`) from # release.yml after the release security gate AND the `release` environment # approval pass. We intentionally do NOT support workflow_dispatch — that # would bypass gates. We also intentionally do NOT support # repository_dispatch any more: with the atomicity refactor, this workflow # runs as a job inside release.yml's run so its result is visible to # downstream jobs (create-release, cleanup-on-rejection) — a property that # repository_dispatch fanout broke. # # This workflow is a thin RETAG step in the build-once-promote pipeline. # The actual build happens in prerelease-docker.yml; the multi-arch manifest # is signed and attested there. Here we only: # - Verify the source manifest digest matches what prerelease produced # - Retag the prerelease manifest to release tags (:1.6.9, :1.6, :latest) # - Verify the digest is preserved (defends against imagetools re-encoding) # - Re-run Trivy against the digest (catches CVE-database updates between # prerelease build and release promote) # - Verify cosign signature transitivity from the original digest # - Clean up the prerelease tags # # To re-publish, trigger a new release through release.yml. on: workflow_call: inputs: tag: description: "Release tag, e.g. 'v1.6.9' (with leading 'v')" type: string required: true source_tag: description: "Prerelease manifest tag to retag, e.g. 'prerelease-v1.6.9-abc1234'" type: string required: true expected_digest: description: "sha256:... digest of the prerelease manifest, captured by prerelease-docker.yml. Used to verify retag preserves the digest end-to-end." type: string required: true secrets: DOCKER_USERNAME: required: true description: "Docker Hub username (env-scoped to `release`)" DOCKER_PASSWORD: required: true description: "Docker Hub PAT with Read+Write+Delete scopes (env-scoped to `release`)" permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions # NOTE: no workflow-level `concurrency:` block. As a reusable workflow # called from release.yml, this workflow runs as part of the caller's run, # and release.yml's caller-level concurrency (keyed on github.workflow + # github.ref) already serialises release runs for the same tag. Adding a # callee-level block would be a no-op for the documented threat model # (two simultaneous releases for the same ref) and would not be reachable # anyway because there is no longer any standalone invocation path. jobs: promote: name: Retag prerelease manifest as release runs-on: ubuntu-latest environment: release permissions: # cosign verify is read-only against public Rekor/Fulcio — does not # mint a GitHub OIDC token, so id-token: write is not required here. # Signing (which does need id-token: write) happens in prerelease-docker.yml. contents: read steps: - name: Harden Runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Check out the repo at the triggering commit # Pin the checkout to the EXACT commit that the prerelease was # built/scanned from, so .trivyignore (and any other repo-state- # dependent file the promote step reads) matches that commit. # We use github.sha, NOT inputs.tag — the v* git tag is created # by create-release LATER in this run (after publish-docker # completes), so it does not exist yet when this checkout runs # on a push-to-main trigger. github.sha is the triggering commit # for every event type (push to main, tag push, workflow_dispatch) # and is the same SHA the build/prerelease-docker jobs used. uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.sha }} 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 to match the version that signed the artifact # in prerelease-docker.yml. Mismatched versions across sign/verify # work today but new-bundle-format (cosign v3 default) would only # produce/consume on v3. cosign-release: 'v2.6.3' - name: Determine release tags id: version env: DISPATCH_TAG: ${{ inputs.tag }} SOURCE_TAG: ${{ inputs.source_tag }} EXPECTED_DIGEST: ${{ inputs.expected_digest }} run: | set -euo pipefail if [[ -z "$DISPATCH_TAG" || -z "$SOURCE_TAG" || -z "$EXPECTED_DIGEST" ]]; then echo "::error::Missing required workflow_call input. Got tag='${DISPATCH_TAG}' source_tag='${SOURCE_TAG}' expected_digest='${EXPECTED_DIGEST}'" exit 1 fi if [[ "$EXPECTED_DIGEST" != sha256:* ]]; then echo "::error::expected_digest must be of the form sha256:... — got '${EXPECTED_DIGEST}'" exit 1 fi VERSION="${DISPATCH_TAG#v}" MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2) # Group writes into one redirect (shellcheck SC2129). { echo "tag=${DISPATCH_TAG}" echo "version=${VERSION}" echo "major_minor=${MAJOR_MINOR}" echo "source_tag=${SOURCE_TAG}" echo "expected_digest=${EXPECTED_DIGEST}" } >> "$GITHUB_OUTPUT" - name: Verify source digest matches expected env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} SOURCE_TAG: ${{ steps.version.outputs.source_tag }} EXPECTED_DIGEST: ${{ steps.version.outputs.expected_digest }} run: | set -euo pipefail # Defends against the prerelease tag being swapped between # prerelease-docker's signing and this promote step. SOURCE="${DOCKER_USERNAME}/local-deep-research:${SOURCE_TAG}" ACTUAL=$(docker buildx imagetools inspect "$SOURCE" --format '{{json .Manifest.Digest}}' | tr -d '"') if [[ "$ACTUAL" != "$EXPECTED_DIGEST" ]]; then echo "::error::Source digest mismatch — possible tag tampering between prerelease and promote" echo " expected: $EXPECTED_DIGEST" echo " actual: $ACTUAL" exit 1 fi echo "Source digest verified: $ACTUAL" - name: Promote (retag) prerelease manifest to release tags env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} SOURCE_TAG: ${{ steps.version.outputs.source_tag }} VERSION: ${{ steps.version.outputs.version }} MAJOR_MINOR: ${{ steps.version.outputs.major_minor }} run: | set -euo pipefail SOURCE="${DOCKER_USERNAME}/local-deep-research:${SOURCE_TAG}" # Single imagetools create with multiple -t — registry-side # metadata-only operation, takes seconds, preserves digest. docker buildx imagetools create \ -t "${DOCKER_USERNAME}/local-deep-research:${VERSION}" \ -t "${DOCKER_USERNAME}/local-deep-research:${MAJOR_MINOR}" \ -t "${DOCKER_USERNAME}/local-deep-research:latest" \ "$SOURCE" echo "Promoted ${SOURCE} to :${VERSION}, :${MAJOR_MINOR}, :latest" - name: Verify promoted tags share the source digest env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} VERSION: ${{ steps.version.outputs.version }} MAJOR_MINOR: ${{ steps.version.outputs.major_minor }} EXPECTED_DIGEST: ${{ steps.version.outputs.expected_digest }} run: | set -euo pipefail # Defends against `imagetools create` re-encoding the manifest. # If digests diverge, signatures and attestations (keyed by the # original digest) won't be discoverable from the new tags. for TAG in "${VERSION}" "${MAJOR_MINOR}" "latest"; do REF="${DOCKER_USERNAME}/local-deep-research:${TAG}" ACTUAL=$(docker buildx imagetools inspect "$REF" --format '{{json .Manifest.Digest}}' | tr -d '"') if [[ "$ACTUAL" != "$EXPECTED_DIGEST" ]]; then echo "::error::Digest mismatch on ${TAG} — imagetools create may have re-encoded the manifest" echo " expected: $EXPECTED_DIGEST" echo " actual: $ACTUAL" exit 1 fi echo "${TAG} -> ${ACTUAL} ✓" done # Catches CVE-database updates that landed between the prerelease # build and this promote step. Use the SHA-pinned action wrapper # (same pin as prerelease-docker.yml's security-scan) with an # explicit binary version pin — the prior `apt-get install -y trivy` # approach was unpinned and exposed the release path to the Trivy # apt-repo supply chain. The pinned action downloads the v0.69.2 # binary from GitHub releases by exact tag, which is the same # binary the prerelease scan validated, keeping the two scans # consistent. # # Unlike prerelease-docker.yml's security-scan (which scans a # locally-loaded image, no registry pull), this step scans by # registry digest — Trivy must pull the manifest + layers from # Docker Hub. TRIVY_USERNAME/TRIVY_PASSWORD is the action's # documented auth path; the `docker/login-action` above also # writes ~/.docker/config.json which Trivy reads as a fallback, # but the explicit env vars are more reliable (Trivy has # documented docker.io credential-helper quirks — aquasecurity/ # trivy#432, aquasecurity/trivy#8385) and the image we scan IS on # Docker Hub so this is the path most likely to keep working. - name: Re-scan release digest with Trivy uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 env: TRIVY_USERNAME: ${{ secrets.DOCKER_USERNAME }} TRIVY_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} with: image-ref: ${{ secrets.DOCKER_USERNAME }}/local-deep-research@${{ steps.version.outputs.expected_digest }} severity: 'CRITICAL,HIGH' ignore-unfixed: true trivyignores: '.trivyignore' exit-code: '1' version: 'v0.69.2' - name: Verify cosign signature on promoted digest env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} EXPECTED_DIGEST: ${{ steps.version.outputs.expected_digest }} REPO: ${{ github.repository }} run: | set -euo pipefail # The cert was issued to the prerelease-docker.yml workflow when # signing happened there, so the identity regex must match that # workflow's path. Fulcio's SAN is built from job_workflow_ref, # which for reusable workflows is the CALLEE. # # Verify by IMMUTABLE digest (not the :VERSION tag) so this step # is invariant under any retag race between the verify-promoted- # tags step above and this one. Trivy's re-scan above also uses # @${EXPECTED_DIGEST}; keeping cosign on the same reference is # consistent and avoids a tag-resolution TOCTOU window. IMAGE_REF="${DOCKER_USERNAME}/local-deep-research@${EXPECTED_DIGEST}" echo "Verifying signature for: $IMAGE_REF" cosign verify \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ --certificate-identity-regexp "^https://github.com/${REPO}/\.github/workflows/prerelease-docker\.yml@refs/(heads|tags)/" \ --certificate-github-workflow-repository "${REPO}" \ "$IMAGE_REF" echo "Signature transitivity verified ✓" - name: Clean up prerelease tags continue-on-error: true env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} RELEASE_VERSION: ${{ steps.version.outputs.version }} run: | set +e # Best-effort: never fail the workflow # Scope deletion to prereleases of THIS version only, so concurrent # prereleases for other versions (and any unrelated prerelease-* tags) # are left untouched. PREFIX="prerelease-v${RELEASE_VERSION}-" echo "Cleaning up ${PREFIX}* tags from Docker Hub..." # Authenticate with Docker Hub API (password-based JWT) TOKEN=$(curl -s -X POST "https://hub.docker.com/v2/users/login/" \ -H "Content-Type: application/json" \ -d "{\"username\": \"${DOCKER_USERNAME}\", \"password\": \"${DOCKER_PASSWORD}\"}" | jq -r '.token') if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then echo "WARNING: Failed to authenticate with Docker Hub API. Skipping cleanup." exit 0 fi REPO="${DOCKER_USERNAME}/local-deep-research" PAGE=1 DELETED=0 while true; do RESPONSE=$(curl -s -H "Authorization: JWT ${TOKEN}" \ "https://hub.docker.com/v2/repositories/${REPO}/tags/?page=${PAGE}&page_size=100") RESULTS=$(echo "$RESPONSE" | jq -r '.results[]?.name // empty') if [ -z "$RESULTS" ]; then break fi while IFS= read -r tag; do if [[ "$tag" == "${PREFIX}"* ]]; then echo "Deleting tag: ${tag}" HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ -H "Authorization: JWT ${TOKEN}" \ "https://hub.docker.com/v2/repositories/${REPO}/tags/${tag}/") if [ "$HTTP_CODE" -eq 204 ] || [ "$HTTP_CODE" -eq 200 ]; then echo " Deleted: ${tag}" DELETED=$((DELETED + 1)) else echo " WARNING: Failed to delete ${tag} (HTTP ${HTTP_CODE})" fi fi done <<< "$RESULTS" # Check if there are more pages NEXT=$(echo "$RESPONSE" | jq -r '.next // empty') if [ -z "$NEXT" ]; then break fi PAGE=$((PAGE + 1)) done echo "Prerelease tag cleanup complete. Deleted ${DELETED} tag(s) matching ${PREFIX}*." - name: Summary env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} VERSION: ${{ steps.version.outputs.version }} MAJOR_MINOR: ${{ steps.version.outputs.major_minor }} EXPECTED_DIGEST: ${{ steps.version.outputs.expected_digest }} run: | { echo "## Docker Release Promoted" echo "" echo "**Digest:** \`${EXPECTED_DIGEST}\`" echo "" echo "**Tags:** \`${VERSION}\`, \`${MAJOR_MINOR}\`, \`latest\` — all share the same digest as the prerelease manifest, so cosign signatures, SBOM, and SLSA provenance from the prerelease step are transitively valid." echo "" echo '```' echo "docker pull ${DOCKER_USERNAME}/local-deep-research:${VERSION}" echo "docker pull ${DOCKER_USERNAME}/local-deep-research:latest" echo '```' } >> "$GITHUB_STEP_SUMMARY"