Files
qinglong/.github/workflows/ql3-image-release.yml
T

533 lines
22 KiB
YAML

name: 'QingLong 3.0 Image Release'
on:
workflow_dispatch:
inputs:
version:
description: Exact QingLong 3 SemVer tag without the v prefix
required: true
type: string
permissions:
contents: read
concurrency:
group: ql3-image-release-${{ github.ref }}
cancel-in-progress: false
jobs:
worker-management-release-evidence:
name: Audit private Worker management release evidence
runs-on: [self-hosted, linux, ql3-release-evidence-ephemeral]
environment: ql3-production-release-evidence
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '24.18.0'
- name: Re-audit commit-scoped private production evidence
env:
RELEASE_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
umask 077
if [[ ! "${GITHUB_SHA}" =~ ^[0-9a-f]{40,64}$ ]]; then
echo "release source commit is malformed" >&2
exit 1
fi
if [[ ! "${RELEASE_VERSION}" =~ ^3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then
echo "release version must be QingLong 3 SemVer" >&2
exit 1
fi
if [[ "${GITHUB_REF_TYPE}" != "tag" || "${GITHUB_REF_NAME}" != "v${RELEASE_VERSION}" ]]; then
echo "workflow dispatch must target the exact protected release tag" >&2
exit 1
fi
evidence_dir="/run/qinglong3-release-evidence/${GITHUB_SHA}"
node scripts/ql3-worker-credential-management-release-gate.cjs \
--report="${evidence_dir}/worker-management-release-evidence.json" \
--ceremony-report="${evidence_dir}/worker-management-ceremony.json" \
--durable-audit-report="${evidence_dir}/worker-management-durable-audit.json" \
--pki-rotation-report="${evidence_dir}/worker-management-pki-rotation-v2.json" \
--ca-rollover-report="${evidence_dir}/worker-management-ca-rollover.json" \
--source-commit="${GITHUB_SHA}" \
--release-version="${RELEASE_VERSION}"
cluster-dr-release-evidence:
name: Audit private CloudNativePG disaster-recovery evidence
runs-on: [self-hosted, linux, ql3-release-evidence-ephemeral]
environment: ql3-production-release-evidence
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '24.18.0'
- name: Re-audit commit-scoped private disaster-recovery evidence
env:
RELEASE_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
umask 077
if [[ ! "${GITHUB_SHA}" =~ ^[0-9a-f]{40,64}$ ]]; then
echo "release source commit is malformed" >&2
exit 1
fi
if [[ ! "${RELEASE_VERSION}" =~ ^3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then
echo "release version must be QingLong 3 SemVer" >&2
exit 1
fi
if [[ "${GITHUB_REF_TYPE}" != "tag" || "${GITHUB_REF_NAME}" != "v${RELEASE_VERSION}" ]]; then
echo "workflow dispatch must target the exact protected release tag" >&2
exit 1
fi
evidence_dir="/run/qinglong3-release-evidence/${GITHUB_SHA}"
report="${evidence_dir}/cloudnativepg-dr-evidence.json"
node scripts/ql3-cloudnativepg-backup-audit.cjs
node scripts/ql3-barman-cloud-supply-chain-audit.cjs
node scripts/ql3-cert-manager-selection-audit.cjs
node scripts/ql3-cloudnativepg-dr-release-gate.cjs \
--report="${report}" \
--source-commit="${GITHUB_SHA}" \
--release-version="${RELEASE_VERSION}"
os-vulnerability:
name: Scan ${{ matrix.image }} OS packages on ${{ matrix.image_arch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- image: control
runner: ubuntu-24.04
node_arch: x64
image_arch: amd64
dockerfile: deploy/containers/ql3-cluster-control/Dockerfile
target: runtime
- image: control
runner: ubuntu-24.04-arm
node_arch: arm64
image_arch: arm64
dockerfile: deploy/containers/ql3-cluster-control/Dockerfile
target: runtime
- image: control-ai
runner: ubuntu-24.04
node_arch: x64
image_arch: amd64
dockerfile: deploy/containers/ql3-cluster-control/Dockerfile
target: runtime-ai
- image: control-ai
runner: ubuntu-24.04-arm
node_arch: arm64
image_arch: arm64
dockerfile: deploy/containers/ql3-cluster-control/Dockerfile
target: runtime-ai
- image: admin
runner: ubuntu-24.04
node_arch: x64
image_arch: amd64
dockerfile: deploy/containers/ql3-cluster-admin/Dockerfile
target: runtime
- image: admin
runner: ubuntu-24.04-arm
node_arch: arm64
image_arch: arm64
dockerfile: deploy/containers/ql3-cluster-admin/Dockerfile
target: runtime
- image: local
runner: ubuntu-24.04
node_arch: x64
image_arch: amd64
dockerfile: deploy/containers/ql3-local-application/Dockerfile
target: runtime
- image: local
runner: ubuntu-24.04-arm
node_arch: arm64
image_arch: arm64
dockerfile: deploy/containers/ql3-local-application/Dockerfile
target: runtime
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '24.18.0'
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Verify native scanner architecture
run: node -e "if (process.arch !== '${{ matrix.node_arch }}') throw new Error('unexpected architecture ' + process.arch)"
- name: Audit the bounded exception lifecycle
run: node scripts/ql3-image-os-vulnerability-policy.cjs
- name: Build the exact native OCI release artifact once
run: |
set -euo pipefail
mkdir -p "${RUNNER_TEMP}/ql3-native"
docker buildx build \
--platform linux/${{ matrix.image_arch }} \
--file ${{ matrix.dockerfile }} \
--target ${{ matrix.target }} \
--build-arg SOURCE_REVISION=${{ github.sha }} \
--attest type=provenance,mode=max \
--attest type=sbom \
--output "type=oci,dest=${RUNNER_TEMP}/ql3-native/image.oci.tar" \
.
- name: Materialize image-scoped Trivy exceptions
run: >-
node scripts/ql3-image-os-vulnerability-policy.cjs
--image=${{ matrix.image }}
--output=${{ runner.temp }}/ql3-${{ matrix.image }}-${{ matrix.image_arch }}.trivyignore.yaml
- name: Reject unexcepted high or critical OS vulnerabilities
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
version: 'v0.70.0'
input: ${{ runner.temp }}/ql3-native/image.oci.tar
scanners: 'vuln'
vuln-type: 'os'
severity: 'HIGH,CRITICAL'
ignore-unfixed: 'false'
exit-code: '1'
format: 'table'
hide-progress: 'true'
timeout: '10m0s'
cache: 'false'
trivyignores: ${{ runner.temp }}/ql3-${{ matrix.image }}-${{ matrix.image_arch }}.trivyignore.yaml
- name: Bind the successful native scan to the OCI digest
run: |
set -euo pipefail
mkdir "${RUNNER_TEMP}/ql3-native/layout"
tar -xf "${RUNNER_TEMP}/ql3-native/image.oci.tar" \
-C "${RUNNER_TEMP}/ql3-native/layout"
node scripts/ql3-image-release-bundle.cjs \
--mode=record-native \
--image=${{ matrix.image }} \
--platform=linux/${{ matrix.image_arch }} \
--layout=${{ runner.temp }}/ql3-native/layout \
--expected-revision=${{ github.sha }} \
--evidence=${{ runner.temp }}/ql3-native/evidence.json
rm "${RUNNER_TEMP}/ql3-native/image.oci.tar"
- name: Upload the scanned immutable native OCI artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ql3-release-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.image }}-${{ matrix.image_arch }}
path: ${{ runner.temp }}/ql3-native
if-no-files-found: error
retention-days: 1
compression-level: 0
overwrite: false
include-hidden-files: false
publish:
name: Publish immutable multi-architecture ${{ matrix.image }} image
needs:
- worker-management-release-evidence
- cluster-dr-release-evidence
- os-vulnerability
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
id-token: write
attestations: write
artifact-metadata: write
strategy:
fail-fast: false
matrix:
include:
- image: control
repository: qinglong3-cluster-control
runtime_root: deploy/containers/ql3-cluster-control/runtime-dependencies
- image: control-ai
repository: qinglong3-cluster-control-ai
runtime_root: deploy/containers/ql3-cluster-control/runtime-dependencies
- image: admin
repository: qinglong3-cluster-admin
runtime_root: deploy/containers/ql3-cluster-admin/runtime-dependencies
- image: local
repository: qinglong3-local-application
runtime_root: deploy/containers/ql3-local-application/runtime-dependencies
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '24.18.0'
- name: Resolve and validate release identity
id: identity
env:
DISPATCH_VERSION: ${{ inputs.version }}
IMAGE_REPOSITORY: ${{ matrix.repository }}
run: |
set -euo pipefail
owner="${GITHUB_REPOSITORY_OWNER,,}"
image="ghcr.io/${owner}/${IMAGE_REPOSITORY}"
version="${DISPATCH_VERSION}"
if [[ ! "${version}" =~ ^3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then
echo "release version must be QingLong 3 SemVer" >&2
exit 1
fi
if [[ "${GITHUB_REF_TYPE}" != "tag" || "${GITHUB_REF_NAME}" != "v${version}" ]]; then
echo "release source must be the exact requested tag" >&2
exit 1
fi
echo "image=${image}" >> "${GITHUB_OUTPUT}"
echo "version=${version}" >> "${GITHUB_OUTPUT}"
- name: Generate and audit the reviewed application SBOM
run: >-
node scripts/ql3-cluster-image-sbom.cjs
--image=${{ matrix.image }}
--output=${{ runner.temp }}/${{ matrix.repository }}.cdx.json
- name: Reject high or critical production dependency advisories
run: >-
npm audit
--omit=dev
--audit-level=high
--prefix=${{ matrix.runtime_root }}
- name: Download the exact scanned amd64 OCI artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ql3-release-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.image }}-amd64
path: ${{ runner.temp }}/native/amd64
- name: Download the exact scanned arm64 OCI artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ql3-release-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.image }}-arm64
path: ${{ runner.temp }}/native/arm64
- name: Merge and re-audit the scanned OCI digest without rebuilding
id: bundle
run: |
set -euo pipefail
node scripts/ql3-image-release-bundle.cjs \
--mode=merge \
--image=${{ matrix.image }} \
--amd64-layout=${RUNNER_TEMP}/native/amd64/layout \
--amd64-evidence=${RUNNER_TEMP}/native/amd64/evidence.json \
--arm64-layout=${RUNNER_TEMP}/native/arm64/layout \
--arm64-evidence=${RUNNER_TEMP}/native/arm64/evidence.json \
--expected-revision=${GITHUB_SHA} \
--output=${RUNNER_TEMP}/${{ matrix.repository }}-release-layout \
--predicate=${RUNNER_TEMP}/${{ matrix.repository }}-os-vulnerability.json \
--report=${RUNNER_TEMP}/${{ matrix.repository }}-bundle.json
digest="$(node -e 'const fs=require("node:fs");const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));if(!/^sha256:[0-9a-f]{64}$/.test(value.rootIndexDigest))process.exit(1);process.stdout.write(value.rootIndexDigest)' "${RUNNER_TEMP}/${{ matrix.repository }}-bundle.json")"
echo "digest=${digest}" >> "${GITHUB_OUTPUT}"
tar -C "${RUNNER_TEMP}/${{ matrix.repository }}-release-layout" \
-cf "${RUNNER_TEMP}/${{ matrix.repository }}-release.oci.tar" .
- name: Install the exact checksum-pinned OCI copier
env:
REGCTL: ${{ runner.temp }}/regctl
run: |
set -euo pipefail
curl --fail --location --proto '=https' --tlsv1.2 \
--output "${REGCTL}" \
https://github.com/regclient/regclient/releases/download/v0.11.5/regctl-linux-amd64
echo "c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467 ${REGCTL}" | sha256sum --check --strict
chmod 0755 "${REGCTL}"
"${REGCTL}" version
- uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Import the scanned OCI graph by digest without a public tag
id: push
env:
IMAGE: ${{ steps.identity.outputs.image }}
DIGEST: ${{ steps.bundle.outputs.digest }}
ARCHIVE: ${{ runner.temp }}/${{ matrix.repository }}-release.oci.tar
REGCTL: ${{ runner.temp }}/regctl
run: |
set -euo pipefail
if [[ ! "${DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "bundle did not return an immutable SHA-256 digest" >&2
exit 1
fi
"${REGCTL}" image import "${IMAGE}@${DIGEST}" "${ARCHIVE}"
remote_digest="$("${REGCTL}" image digest "${IMAGE}@${DIGEST}")"
if [[ "${remote_digest}" != "${DIGEST}" ]]; then
echo "registry changed the scanned OCI digest" >&2
exit 1
fi
echo "digest=${DIGEST}" >> "${GITHUB_OUTPUT}"
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Keylessly sign the immutable manifest digest
env:
IMAGE: ${{ steps.identity.outputs.image }}
DIGEST: ${{ steps.push.outputs.digest }}
run: cosign sign --yes "${IMAGE}@${DIGEST}"
- name: Attest SLSA build provenance
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with:
subject-name: ${{ steps.identity.outputs.image }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
- name: Attest the reviewed CycloneDX application SBOM
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with:
subject-name: ${{ steps.identity.outputs.image }}
subject-digest: ${{ steps.push.outputs.digest }}
sbom-path: ${{ runner.temp }}/${{ matrix.repository }}.cdx.json
push-to-registry: true
- name: Attest the digest-bound OS vulnerability evidence
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with:
subject-name: ${{ steps.identity.outputs.image }}
subject-digest: ${{ steps.push.outputs.digest }}
predicate-type: https://qinglong.dev/attestations/image-os-vulnerability/v1
predicate-path: ${{ runner.temp }}/${{ matrix.repository }}-os-vulnerability.json
push-to-registry: true
- name: Verify the published manifest and attestation bindings
env:
IMAGE: ${{ steps.identity.outputs.image }}
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -euo pipefail
manifest="${RUNNER_TEMP}/${{ matrix.repository }}-index.json"
docker buildx imagetools inspect --raw "${IMAGE}@${DIGEST}" > "${manifest}"
node scripts/ql3-cluster-remote-manifest-audit.cjs \
--manifest="${manifest}" \
--expected-image="${IMAGE}" \
--expected-digest="${DIGEST}"
- name: Verify local rollout compatibility against the pushed digest
if: matrix.image == 'local'
env:
IMAGE: ${{ steps.identity.outputs.image }}
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -euo pipefail
corepack enable
pnpm install --frozen-lockfile --ignore-scripts
pnpm --filter @qinglong/local-owner-cli check
docker pull "${IMAGE}@${DIGEST}"
docker_executable="$(realpath "$(command -v docker)")"
docker_socket="$(realpath /var/run/docker.sock)"
node scripts/ql3-local-compose-rollout-live-contract.cjs \
--image="${IMAGE}@${DIGEST}" \
--docker-executable="${docker_executable}" \
--docker-socket="${docker_socket}" \
--profile=edge
node scripts/ql3-local-compose-rollout-live-contract.cjs \
--image="${IMAGE}@${DIGEST}" \
--docker-executable="${docker_executable}" \
--docker-socket="${docker_socket}" \
--profile=standalone
- name: Verify the keyless signature identity
env:
IMAGE: ${{ steps.identity.outputs.image }}
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -euo pipefail
certificate_identity="https://github.com/${GITHUB_REPOSITORY}/.github/workflows/ql3-image-release.yml@${GITHUB_REF}"
cosign verify \
--certificate-identity "${certificate_identity}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"${IMAGE}@${DIGEST}"
- name: Verify GitHub SLSA provenance from the published OCI registry
env:
GH_TOKEN: ${{ github.token }}
IMAGE: ${{ steps.identity.outputs.image }}
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -euo pipefail
gh attestation verify "oci://${IMAGE}@${DIGEST}" \
--repo "${GITHUB_REPOSITORY}" \
--signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/ql3-image-release.yml" \
--source-digest "${GITHUB_SHA}" \
--source-ref "${GITHUB_REF}" \
--deny-self-hosted-runners \
--bundle-from-oci
- name: Verify the reviewed CycloneDX attestation from the OCI registry
env:
GH_TOKEN: ${{ github.token }}
IMAGE: ${{ steps.identity.outputs.image }}
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -euo pipefail
gh attestation verify "oci://${IMAGE}@${DIGEST}" \
--repo "${GITHUB_REPOSITORY}" \
--signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/ql3-image-release.yml" \
--source-digest "${GITHUB_SHA}" \
--source-ref "${GITHUB_REF}" \
--predicate-type "https://cyclonedx.org/bom" \
--deny-self-hosted-runners \
--bundle-from-oci
- name: Verify the OS vulnerability evidence from the OCI registry
env:
GH_TOKEN: ${{ github.token }}
IMAGE: ${{ steps.identity.outputs.image }}
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -euo pipefail
gh attestation verify "oci://${IMAGE}@${DIGEST}" \
--repo "${GITHUB_REPOSITORY}" \
--signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/ql3-image-release.yml" \
--source-digest "${GITHUB_SHA}" \
--source-ref "${GITHUB_REF}" \
--predicate-type "https://qinglong.dev/attestations/image-os-vulnerability/v1" \
--deny-self-hosted-runners \
--bundle-from-oci
- name: Promote only the verified digest to immutable release tags
env:
IMAGE: ${{ steps.identity.outputs.image }}
VERSION: ${{ steps.identity.outputs.version }}
DIGEST: ${{ steps.push.outputs.digest }}
REGCTL: ${{ runner.temp }}/regctl
run: |
set -euo pipefail
"${REGCTL}" image copy "${IMAGE}@${DIGEST}" "${IMAGE}:${VERSION}"
"${REGCTL}" image copy "${IMAGE}@${DIGEST}" "${IMAGE}:sha-${GITHUB_SHA}"
if [[ "$("${REGCTL}" image digest "${IMAGE}:${VERSION}")" != "${DIGEST}" ]]; then
echo "release version tag does not resolve to the verified digest" >&2
exit 1
fi
if [[ "$("${REGCTL}" image digest "${IMAGE}:sha-${GITHUB_SHA}")" != "${DIGEST}" ]]; then
echo "release source tag does not resolve to the verified digest" >&2
exit 1
fi