7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
335 lines
16 KiB
YAML
335 lines
16 KiB
YAML
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"
|