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