feat(ql3): gate release tags on deployment readiness

This commit is contained in:
whyour
2026-08-18 04:54:10 +08:00
parent a99f1508f7
commit 1c4e3269b4
15 changed files with 2071 additions and 267 deletions
+1
View File
@@ -303,6 +303,7 @@ jobs:
test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs
test/back/ql3ReleaseSetContract.test.cjs test/back/ql3ReleaseSetContract.test.cjs
test/back/ql3ReleaseCatalogContract.test.cjs test/back/ql3ReleaseCatalogContract.test.cjs
test/back/ql3ReleaseDeploymentReadinessContract.test.cjs
test/back/ql3ReleasePublicationClosureContract.test.cjs test/back/ql3ReleasePublicationClosureContract.test.cjs
test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs
test/back/ql3DeploymentLockContract.test.cjs test/back/ql3DeploymentLockContract.test.cjs
+398 -170
View File
@@ -673,7 +673,7 @@ jobs:
include-hidden-files: false include-hidden-files: false
release-set: release-set:
name: Close and publish the complete deployment release set name: Close the release set and publish its immutable catalog
needs: needs:
- release-candidate - release-candidate
- worker-management-release-evidence - worker-management-release-evidence
@@ -962,172 +962,10 @@ jobs:
with: with:
subject-path: ${{ steps.catalog-receipt.outputs.receipt }} subject-path: ${{ steps.catalog-receipt.outputs.receipt }}
- name: Materialize the catalog-authorized final tag publication plan - name: Upload same-run catalog publisher evidence
id: final-publication
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_SCOPE: ${{ inputs.release_scope }}
RELEASE_SET: ${{ steps.release-set.outputs.report }}
CATALOG_PLAN: ${{ steps.release-set.outputs.plan }}
CATALOG_MANIFEST: ${{ steps.catalog.outputs.manifest }}
CATALOG_MANIFEST_DIGEST: ${{ steps.catalog.outputs.digest }}
CATALOG_RECEIPT: ${{ steps.catalog-receipt.outputs.receipt }}
BUNDLE: ${{ steps.release-set.outputs.bundle }}
run: |
set -euo pipefail
owner="${GITHUB_REPOSITORY_OWNER,,}"
source_repository="${GITHUB_REPOSITORY,,}"
plan="${BUNDLE}/qinglong3-release-publication-plan-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
observations="${BUNDLE}/qinglong3-release-publication-tag-observation-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
receipt="${BUNDLE}/qinglong3-release-publication-closure-receipt-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
node scripts/ql3-release-publication-closure-contract.cjs \
--mode=plan \
--version="${RELEASE_VERSION}" \
--source-revision="${GITHUB_SHA}" \
--source-ref="${GITHUB_REF}" \
--release-scope="${RELEASE_SCOPE}" \
--repository-owner="${owner}" \
--source-repository="${source_repository}" \
--release-set="${RELEASE_SET}" \
--catalog-plan="${CATALOG_PLAN}" \
--catalog-manifest="${CATALOG_MANIFEST}" \
--catalog-manifest-digest="${CATALOG_MANIFEST_DIGEST}" \
--catalog-receipt="${CATALOG_RECEIPT}" \
--output="${plan}" > /dev/null
echo "plan=${plan}" >> "${GITHUB_OUTPUT}"
echo "observations=${observations}" >> "${GITHUB_OUTPUT}"
echo "receipt=${receipt}" >> "${GITHUB_OUTPUT}"
- name: Promote final tags only after the catalog receipt is attested
env:
REGCTL: ${{ runner.temp }}/regctl
PUBLICATION_PLAN: ${{ steps.final-publication.outputs.plan }}
TAG_OBSERVATIONS: ${{ steps.final-publication.outputs.observations }}
run: |
set -euo pipefail
umask 077
node <<'NODE'
const fs = require('node:fs');
const { spawnSync } = require('node:child_process');
const {
createPublicationTagObservation,
} = require('./scripts/ql3-release-publication-closure-contract.cjs');
const plan = JSON.parse(fs.readFileSync(process.env.PUBLICATION_PLAN, 'utf8'));
const regctl = process.env.REGCTL;
const maxInventoryBytes = 1024 * 1024;
const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/u;
const run = (args) => {
const result = spawnSync(regctl, args, {
encoding: 'utf8',
maxBuffer: maxInventoryBytes,
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`regctl ${args.join(' ')} failed`);
}
return result.stdout;
};
const states = [];
for (const image of plan.images) {
const source = run(['image', 'digest', image.immutableReference]).trim();
if (source !== image.digest) {
throw new Error('source digest drifted before promotion');
}
const inventoryContents = run([
'tag',
'ls',
image.registryRepository,
'--format',
'{{ range .Tags }}{{ println . }}{{ end }}',
]);
if (
Buffer.byteLength(inventoryContents) > maxInventoryBytes ||
(inventoryContents.length > 0 && !inventoryContents.endsWith('\n'))
) {
throw new Error('release tag inventory is invalid or unbounded');
}
const inventory = inventoryContents.length === 0
? []
: inventoryContents.slice(0, -1).split('\n');
if (
inventory.some((tag) => !tagPattern.test(tag)) ||
new Set(inventory).size !== inventory.length
) {
throw new Error('release tag inventory is malformed');
}
const inventorySet = new Set(inventory);
for (const tag of image.tags) {
const tagName = tag.reference.slice(image.registryRepository.length + 1);
const present = inventorySet.has(tagName);
if (present) {
const current = run(['image', 'digest', tag.reference]).trim();
if (current !== image.digest) {
throw new Error('release tag already points at another digest');
}
}
states.push({ image, tag, present });
}
}
for (const state of states) {
if (!state.present) {
run([
'image',
'copy',
state.image.immutableReference,
state.tag.reference,
]);
}
}
const observedTags = [];
for (const state of states) {
const promoted = run(['image', 'digest', state.tag.reference]).trim();
if (promoted !== state.image.digest) {
throw new Error('promoted tag does not resolve to the release-set digest');
}
observedTags.push({
image: state.image.name,
kind: state.tag.kind,
reference: state.tag.reference,
digest: promoted,
});
}
const observation = createPublicationTagObservation(plan, observedTags);
const descriptor = fs.openSync(process.env.TAG_OBSERVATIONS, 'wx', 0o600);
try {
fs.writeFileSync(descriptor, `${JSON.stringify(observation)}\n`);
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
NODE
- name: Close and audit the final public tag set
env:
PUBLICATION_PLAN: ${{ steps.final-publication.outputs.plan }}
TAG_OBSERVATIONS: ${{ steps.final-publication.outputs.observations }}
CLOSURE_RECEIPT: ${{ steps.final-publication.outputs.receipt }}
run: |
set -euo pipefail
node scripts/ql3-release-publication-closure-contract.cjs \
--mode=close \
--plan="${PUBLICATION_PLAN}" \
--observations="${TAG_OBSERVATIONS}" \
--output="${CLOSURE_RECEIPT}" > /dev/null
node scripts/ql3-release-publication-closure-contract.cjs \
--mode=audit \
--plan="${PUBLICATION_PLAN}" \
--observations="${TAG_OBSERVATIONS}" \
--receipt="${CLOSURE_RECEIPT}" > "${RUNNER_TEMP}/release-publication-closure-audit.json"
- name: Attest the immutable release publication closure receipt
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with:
subject-path: ${{ steps.final-publication.outputs.receipt }}
- name: Publish the deployment digest lock
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }} name: ql3-release-catalog-publisher-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.release-set.outputs.bundle }} path: ${{ steps.release-set.outputs.bundle }}
if-no-files-found: error if-no-files-found: error
retention-days: 90 retention-days: 90
@@ -1191,8 +1029,8 @@ jobs:
umask 077 umask 077
owner="${REPOSITORY_OWNER,,}" owner="${REPOSITORY_OWNER,,}"
source_repository="${SOURCE_REPOSITORY,,}" source_repository="${SOURCE_REPOSITORY,,}"
private_root="${RUNNER_TEMP}/ql3-release-catalog-local-consumer" private_root="${RUNNER_TEMP}/ql3-release-catalog-local-deployment"
bundle="${private_root}/bundle" bundle="${private_root}/consumption"
token="${private_root}/github-token" token="${private_root}/github-token"
install -d -m 0700 "${private_root}" install -d -m 0700 "${private_root}"
install -m 0600 /dev/null "${token}" install -m 0600 /dev/null "${token}"
@@ -1393,8 +1231,8 @@ jobs:
umask 077 umask 077
owner="${REPOSITORY_OWNER,,}" owner="${REPOSITORY_OWNER,,}"
source_repository="${SOURCE_REPOSITORY,,}" source_repository="${SOURCE_REPOSITORY,,}"
private_root="${RUNNER_TEMP}/ql3-release-catalog-consumer" private_root="${RUNNER_TEMP}/ql3-release-catalog-deployment"
bundle="${private_root}/bundle" bundle="${private_root}/consumption"
token="${private_root}/github-token" token="${private_root}/github-token"
install -d -m 0700 "${private_root}" install -d -m 0700 "${private_root}"
install -m 0600 /dev/null "${token}" install -m 0600 /dev/null "${token}"
@@ -1473,9 +1311,399 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: ql3-release-catalog-deployment-${{ github.run_id }}-${{ github.run_attempt }} name: ql3-release-catalog-deployment-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ql3-release-catalog-deployment/report.json path: ${{ runner.temp }}/ql3-release-catalog-deployment
if-no-files-found: warn if-no-files-found: warn
retention-days: 90 retention-days: 90
compression-level: 9 compression-level: 9
overwrite: false overwrite: false
include-hidden-files: false include-hidden-files: false
release-finalization:
name: Publish final tags only after catalog-bound deployment readiness
needs:
- release-set
- release-catalog-local-deployment-live
- release-catalog-deployment-live
if: >-
always() &&
needs.release-set.result == 'success' &&
(
(
inputs.release_scope == 'local' &&
needs.release-catalog-local-deployment-live.result == 'success' &&
needs.release-catalog-deployment-live.result == 'skipped'
) ||
(
inputs.release_scope == 'cluster' &&
needs.release-catalog-local-deployment-live.result == 'skipped' &&
needs.release-catalog-deployment-live.result == 'success'
) ||
(
inputs.release_scope == 'all' &&
needs.release-catalog-local-deployment-live.result == 'success' &&
needs.release-catalog-deployment-live.result == 'success'
)
)
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
packages: write
id-token: write
attestations: write
artifact-metadata: write
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '24.18.0'
- name: Install the frozen workspace without lifecycle scripts
run: |
set -euo pipefail
corepack enable
corepack prepare pnpm@8.3.1 --activate
pnpm install --frozen-lockfile --ignore-scripts
- name: Download same-run Local deployment evidence
if: inputs.release_scope != 'cluster'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ql3-release-catalog-local-deployment-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ql3-release-finalization/deployment-evidence/local
- name: Download same-run Cluster deployment evidence
if: inputs.release_scope != 'local'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ql3-release-catalog-deployment-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ql3-release-finalization/deployment-evidence/cluster
- name: Normalize and close the downloaded evidence inventory
env:
FINALIZATION_ROOT: ${{ runner.temp }}/ql3-release-finalization
RELEASE_SCOPE: ${{ inputs.release_scope }}
shell: bash
run: |
set -euo pipefail
umask 077
while IFS= read -r -d '' directory; do
chmod 0700 "${directory}"
done < <(find "${FINALIZATION_ROOT}" -type d -print0)
while IFS= read -r -d '' file; do
chmod 0600 "${file}"
done < <(find "${FINALIZATION_ROOT}" -type f -print0)
if [[ "${RELEASE_SCOPE}" != 'cluster' ]]; then
test -f "${FINALIZATION_ROOT}/deployment-evidence/local/edge.json"
test -f "${FINALIZATION_ROOT}/deployment-evidence/local/standalone.json"
test "$(find "${FINALIZATION_ROOT}/deployment-evidence/local" -type f | wc -l | tr -d ' ')" = 5
fi
if [[ "${RELEASE_SCOPE}" != 'local' ]]; then
test -f "${FINALIZATION_ROOT}/deployment-evidence/cluster/report.json"
test "$(find "${FINALIZATION_ROOT}/deployment-evidence/cluster" -type f | wc -l | tr -d ' ')" = 4
fi
- name: Install the exact checksum-pinned catalog reader
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: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Independently consume the catalog in the finalizer
id: finalizer-consumption
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_SCOPE: ${{ inputs.release_scope }}
REPOSITORY_OWNER: ${{ github.repository_owner }}
SOURCE_REPOSITORY: ${{ github.repository }}
REGCTL: ${{ runner.temp }}/regctl
FINALIZATION_ROOT: ${{ runner.temp }}/ql3-release-finalization
shell: bash
run: |
set -euo pipefail
umask 077
owner="${REPOSITORY_OWNER,,}"
source_repository="${SOURCE_REPOSITORY,,}"
bundle="${FINALIZATION_ROOT}/catalog-consumption"
token="${FINALIZATION_ROOT}/github-token"
install -m 0600 /dev/null "${token}"
printf '%s' "${GH_TOKEN}" > "${token}"
trap 'rm -f "${token}"' EXIT
cosign="$(readlink -f "$(command -v cosign)")"
gh="$(readlink -f "$(command -v gh)")"
node scripts/ql3-release-catalog-consumption-ceremony.cjs \
--mode=create \
--version="${RELEASE_VERSION}" \
--source-revision="${GITHUB_SHA}" \
--source-ref="${GITHUB_REF}" \
--release-scope="${RELEASE_SCOPE}" \
--repository-owner="${owner}" \
--source-repository="${source_repository}" \
--output-directory="${bundle}" \
--regctl="${REGCTL}" \
--cosign="${cosign}" \
--gh="${gh}" \
--github-token-file="${token}" > /dev/null
node scripts/ql3-release-catalog-consumption-ceremony.cjs \
--mode=audit \
--version="${RELEASE_VERSION}" \
--source-revision="${GITHUB_SHA}" \
--source-ref="${GITHUB_REF}" \
--release-scope="${RELEASE_SCOPE}" \
--repository-owner="${owner}" \
--source-repository="${source_repository}" \
--output-directory="${bundle}" > /dev/null
rm -f "${token}"
trap - EXIT
echo "bundle=${bundle}" >> "${GITHUB_OUTPUT}"
echo "repository-owner=${owner}" >> "${GITHUB_OUTPUT}"
echo "source-repository=${source_repository}" >> "${GITHUB_OUTPUT}"
- name: Materialize and audit deployment readiness and publication plan
id: final-publication
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_SCOPE: ${{ inputs.release_scope }}
REPOSITORY_OWNER: ${{ steps.finalizer-consumption.outputs.repository-owner }}
SOURCE_REPOSITORY: ${{ steps.finalizer-consumption.outputs.source-repository }}
FINALIZER_CONSUMPTION: ${{ steps.finalizer-consumption.outputs.bundle }}
FINALIZATION_ROOT: ${{ runner.temp }}/ql3-release-finalization
shell: bash
run: |
set -euo pipefail
umask 077
release_set="${FINALIZER_CONSUMPTION}/qinglong3-release-set-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
manifest="${FINALIZER_CONSUMPTION}/qinglong3-release-catalog-manifest-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
manifest_digest="sha256:$(sha256sum "${manifest}" | cut -d ' ' -f 1)"
catalog_plan="${FINALIZATION_ROOT}/qinglong3-release-catalog-plan-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
catalog_receipt="${FINALIZATION_ROOT}/qinglong3-release-catalog-receipt-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
readiness="${FINALIZATION_ROOT}/qinglong3-release-deployment-readiness-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
publication_plan="${FINALIZATION_ROOT}/qinglong3-release-publication-plan-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
observations="${FINALIZATION_ROOT}/qinglong3-release-publication-tag-observation-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
closure="${FINALIZATION_ROOT}/qinglong3-release-publication-closure-receipt-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
node scripts/ql3-release-catalog-contract.cjs \
--mode=plan \
--version="${RELEASE_VERSION}" \
--source-revision="${GITHUB_SHA}" \
--source-ref="${GITHUB_REF}" \
--release-scope="${RELEASE_SCOPE}" \
--repository-owner="${REPOSITORY_OWNER}" \
--source-repository="${SOURCE_REPOSITORY}" \
--release-set="${release_set}" \
--output="${catalog_plan}" > /dev/null
node scripts/ql3-release-catalog-contract.cjs \
--mode=receipt \
--plan="${catalog_plan}" \
--manifest="${manifest}" \
--manifest-digest="${manifest_digest}" \
--output="${catalog_receipt}" > /dev/null
readiness_args=(
--version="${RELEASE_VERSION}"
--source-revision="${GITHUB_SHA}"
--source-ref="${GITHUB_REF}"
--release-scope="${RELEASE_SCOPE}"
--repository-owner="${REPOSITORY_OWNER}"
--source-repository="${SOURCE_REPOSITORY}"
--finalizer-consumption-bundle="${FINALIZER_CONSUMPTION}"
)
if [[ "${RELEASE_SCOPE}" != 'cluster' ]]; then
readiness_args+=(
--local-consumption-bundle="${FINALIZATION_ROOT}/deployment-evidence/local/consumption"
--edge-report="${FINALIZATION_ROOT}/deployment-evidence/local/edge.json"
--standalone-report="${FINALIZATION_ROOT}/deployment-evidence/local/standalone.json"
)
fi
if [[ "${RELEASE_SCOPE}" != 'local' ]]; then
readiness_args+=(
--cluster-consumption-bundle="${FINALIZATION_ROOT}/deployment-evidence/cluster/consumption"
--cluster-report="${FINALIZATION_ROOT}/deployment-evidence/cluster/report.json"
)
fi
node scripts/ql3-release-deployment-readiness-contract.cjs \
--mode=create \
"${readiness_args[@]}" \
--output="${readiness}" > /dev/null
node scripts/ql3-release-deployment-readiness-contract.cjs \
--mode=audit \
"${readiness_args[@]}" \
--receipt="${readiness}" > "${RUNNER_TEMP}/release-deployment-readiness-audit.json"
node scripts/ql3-release-publication-closure-contract.cjs \
--mode=plan \
--version="${RELEASE_VERSION}" \
--source-revision="${GITHUB_SHA}" \
--source-ref="${GITHUB_REF}" \
--release-scope="${RELEASE_SCOPE}" \
--repository-owner="${REPOSITORY_OWNER}" \
--source-repository="${SOURCE_REPOSITORY}" \
--release-set="${release_set}" \
--catalog-plan="${catalog_plan}" \
--catalog-manifest="${manifest}" \
--catalog-manifest-digest="${manifest_digest}" \
--catalog-receipt="${catalog_receipt}" \
--deployment-readiness="${readiness}" \
--output="${publication_plan}" > /dev/null
echo "plan=${publication_plan}" >> "${GITHUB_OUTPUT}"
echo "observations=${observations}" >> "${GITHUB_OUTPUT}"
echo "closure=${closure}" >> "${GITHUB_OUTPUT}"
echo "readiness=${readiness}" >> "${GITHUB_OUTPUT}"
echo "root=${FINALIZATION_ROOT}" >> "${GITHUB_OUTPUT}"
- name: Attest deployment readiness before any final tag mutation
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with:
subject-path: ${{ steps.final-publication.outputs.readiness }}
- uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Promote final tags only after every required deployment gate
env:
REGCTL: ${{ runner.temp }}/regctl
PUBLICATION_PLAN: ${{ steps.final-publication.outputs.plan }}
TAG_OBSERVATIONS: ${{ steps.final-publication.outputs.observations }}
run: |
set -euo pipefail
umask 077
node <<'NODE'
const fs = require('node:fs');
const { spawnSync } = require('node:child_process');
const {
createPublicationTagObservation,
} = require('./scripts/ql3-release-publication-closure-contract.cjs');
const plan = JSON.parse(fs.readFileSync(process.env.PUBLICATION_PLAN, 'utf8'));
const regctl = process.env.REGCTL;
const maxInventoryBytes = 1024 * 1024;
const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/u;
const run = (args) => {
const result = spawnSync(regctl, args, {
encoding: 'utf8',
maxBuffer: maxInventoryBytes,
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`regctl ${args.join(' ')} failed`);
}
return result.stdout;
};
const states = [];
for (const image of plan.images) {
const source = run(['image', 'digest', image.immutableReference]).trim();
if (source !== image.digest) {
throw new Error('source digest drifted before promotion');
}
const inventoryContents = run([
'tag',
'ls',
image.registryRepository,
'--format',
'{{ range .Tags }}{{ println . }}{{ end }}',
]);
if (
Buffer.byteLength(inventoryContents) > maxInventoryBytes ||
(inventoryContents.length > 0 && !inventoryContents.endsWith('\n'))
) {
throw new Error('release tag inventory is invalid or unbounded');
}
const inventory = inventoryContents.length === 0
? []
: inventoryContents.slice(0, -1).split('\n');
if (
inventory.some((tag) => !tagPattern.test(tag)) ||
new Set(inventory).size !== inventory.length
) {
throw new Error('release tag inventory is malformed');
}
const inventorySet = new Set(inventory);
for (const tag of image.tags) {
const tagName = tag.reference.slice(image.registryRepository.length + 1);
const present = inventorySet.has(tagName);
if (present) {
const current = run(['image', 'digest', tag.reference]).trim();
if (current !== image.digest) {
throw new Error('release tag already points at another digest');
}
}
states.push({ image, tag, present });
}
}
for (const state of states) {
if (!state.present) {
run([
'image',
'copy',
state.image.immutableReference,
state.tag.reference,
]);
}
}
const observedTags = [];
for (const state of states) {
const promoted = run(['image', 'digest', state.tag.reference]).trim();
if (promoted !== state.image.digest) {
throw new Error('promoted tag does not resolve to the release-set digest');
}
observedTags.push({
image: state.image.name,
kind: state.tag.kind,
reference: state.tag.reference,
digest: promoted,
});
}
const observation = createPublicationTagObservation(plan, observedTags);
const descriptor = fs.openSync(process.env.TAG_OBSERVATIONS, 'wx', 0o600);
try {
fs.writeFileSync(descriptor, `${JSON.stringify(observation)}\n`);
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
NODE
- name: Close and audit the deployment-ready public tag set
env:
PUBLICATION_PLAN: ${{ steps.final-publication.outputs.plan }}
TAG_OBSERVATIONS: ${{ steps.final-publication.outputs.observations }}
CLOSURE_RECEIPT: ${{ steps.final-publication.outputs.closure }}
run: |
set -euo pipefail
node scripts/ql3-release-publication-closure-contract.cjs \
--mode=close \
--plan="${PUBLICATION_PLAN}" \
--observations="${TAG_OBSERVATIONS}" \
--output="${CLOSURE_RECEIPT}" > /dev/null
node scripts/ql3-release-publication-closure-contract.cjs \
--mode=audit \
--plan="${PUBLICATION_PLAN}" \
--observations="${TAG_OBSERVATIONS}" \
--receipt="${CLOSURE_RECEIPT}" > "${RUNNER_TEMP}/release-publication-closure-audit.json"
- name: Attest the deployment-ready release publication closure receipt
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with:
subject-path: ${{ steps.final-publication.outputs.closure }}
- name: Upload the final deployment-ready release bundle
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}
path: ${{ steps.final-publication.outputs.root }}
if-no-files-found: error
retention-days: 90
compression-level: 0
overwrite: false
include-hidden-files: false
+22
View File
@@ -11,6 +11,28 @@
最新增量证据(2026-08-18): 最新增量证据(2026-08-18):
- D-351/ADR-0443(已接受;首份真实 GHCR deployment-ready finalization 待实际 release tag):D-350 的 catalog-ready
publication 继续收紧为 deployment-ready publication。`release-set` job 只发布、验签并 attested immutable catalog,不再登录 registry
或修改最终 image tag`local|all` 必须先完成 Edge 与 Standalone 的 catalog-bound 正式 Compose rollout`cluster|all` 必须先完成三节点
K3s install、Head 与 UID/resourceVersion fenced retirement。两个只读部署 job 现在连同 content-free report 保存各自 exact-three-file
catalog consumption bundle;终态 `release-finalization` 按 scope 强制精确 success/skipped 真值表、下载并复验 exact 文件集合,再第三次独立消费
公开 immutable catalog。新增 `qinglong/release-deployment-readiness-receipt@v1` 联合绑定 finalizer consumption、要求的部署族、每个
consumption/report digest、release-set/catalog identity 与清理结果,拒绝 synthetic/incomplete/unclean、跨 scope 或 catalog-detached
evidence,并在任何 Docker login/tag mutation 前本地复审和单独 attested。publication plan/closure receipt 升为 v2,发布 authority 为
`verified_catalog_bound_deployments`D-350 的 bounded inventory、零写 conflict preflight、exact reuse、absent copy、终态 tag 回读和
`crossRepositoryAtomicity=false`/`registryTagCas=false` 均保留。最终 90 天 bundle 闭合 finalizer catalog、部署 evidence、readiness、plan、
observation 与 closure。该变化不新增 package、生产依赖、数据库、migration、Kubernetes object 或设备常驻资源;Local 不承担 K3s 门,
Cluster 不承担 Compose 门,Edge/Standalone/路由设备与集群节点稳态成本仍为零。阶段门已重跑:readiness/publication/workflow/Console
distribution 聚焦测试 112/112,完整 backend 1,393 pass + 2 条条件 skip/0 fail18-package clean build/test 退出 014/14 静态审计与
14/14 artifact 档位全部 compatibleartifact 字节保持基础 Edge/Standalone `2589890/2589968`、adopted
`2809185/2809308`、application `3632769/3632889`、application-api `3800322/3800466`、AI `3069143/3069233`
application+AI `4493043/4493175`、MCP `7315930/7316038`。真实 Docker 三节点 K3s v1.34.3/arm64 fixture 完成 install、Head、
UID/resourceVersion fenced retirement、receipt audit 与零残留;fixture 明确保持 `synthetic_live_fixture`,不冒充公开 catalog 证据。
PostgreSQL 18.6/arm64 HA 再次通过 142/142、timeline `1→2`,报告 SHA-256 为
`d7bb8164b46c1392518eb08bea338640639fe5eca71a7401d98d98ea94146fd2`,离线复审通过且容器/卷/网络残留为零。本机成功构建 exact Local
image,但 Local selection 有意只接受真实 `ghcr.io/<owner>/...@sha256`,因此本地 loopback registry 和 image-id 伪装均失败关闭;首份真实
Local catalog-bound rollout 与完整 deployment-ready finalization 仍只能由受保护 release tag 产生。
- D-350/ADR-0442(已接受;首份真实 GHCR 部分 promotion/replay 证据待实际 release tag):正式 image tag 不再在完整 - D-350/ADR-0442(已接受;首份真实 GHCR 部分 promotion/replay 证据待实际 release tag):正式 image tag 不再在完整
release-set 审计后、durable catalog 建立前提前公开。`versionTag/sourceTag` mutation 现位于 release-set provenance、catalog release-set 审计后、durable catalog 建立前提前公开。`versionTag/sourceTag` mutation 现位于 release-set provenance、catalog
immutable round-trip、catalog signature/provenance 验证、catalog receipt 审计及其 attestation 全部成功之后。新的 immutable round-trip、catalog signature/provenance 验证、catalog receipt 审计及其 attestation 全部成功之后。新的
@@ -1,10 +1,11 @@
# ADR-0442Catalog-ready 的终态 Release Tag 发布与闭合收据 # ADR-0442Catalog-ready 的终态 Release Tag 发布与闭合收据
- 状态:Accepted - 状态:Superseded by ADR-0443bounded promotion/closure 机制继续有效,发布前置顺序被收紧)
- 日期:2026-08-18 - 日期:2026-08-18
- 关联 RFCQL-RFC-0001 D-03、D-14、D-336、D-349、D-350 - 关联 RFCQL-RFC-0001 D-03、D-14、D-336、D-349、D-350
- 关联 ADRADR-0427、ADR-0428、ADR-0439、ADR-0441 - 关联 ADRADR-0427、ADR-0428、ADR-0439、ADR-0441
- SupersedesADR-0427 中“完整 release-set 审计后即可 promotion”的最早发布顺序 - SupersedesADR-0427 中“完整 release-set 审计后即可 promotion”的最早发布顺序
- Superseded byADR-0443 将最终 tag mutation 继续后移到 scope-exact deployment readiness attestation 之后
## 上下文 ## 上下文
@@ -0,0 +1,83 @@
# ADR-0443Deployment-ready 的终态 Release Finalization
- 状态:Accepted
- 日期:2026-08-18
- 关联 RFCQL-RFC-0001 D-03、D-14、D-336、D-344、D-345、D-350、D-351
- 关联 ADRADR-0436、ADR-0437、ADR-0441、ADR-0442
- SupersedesADR-0442 中“catalog receipt attested 后即可 mutation 最终 image tag”的发布顺序
## 上下文
ADR-0442 已把最终 image tag 从 release-set 聚合点后移到 immutable catalog 建立并验签之后,但 Local Compose 与 Cluster K3s
下游门仍在该次 tag mutation 之后运行。若 catalog 可验证而 Edge、Standalone 或三节点 K3s 的真实部署链失败,公开
`versionTag/sourceTag` 已经存在,却没有与本次 scope 对应的 catalog-bound 部署证据。此时“catalog-ready”只证明发布材料完整,不能证明
该发布已进入要求的部署族。
同时,部署 job 原先只上传 content-free report,不保留生成 report 时独立消费的 catalog ceremony bundle。终态发布者无法证明 report
确实来自与自身复验相同的 immutable catalog,也无法在一个可离线审计的 readiness receipt 中闭合 catalog、scope 和部署结果。
## 决策
1. `release-set` job 只负责聚合、审计、签证 release-set,发布并验证 immutable OCI catalog,以及签证 catalog receipt。它不再拥有最终
image tag mutation、registry login 或 publication closure 权限。
2. `local|all` 必须成功完成 Edge 与 Standalone 两个正式 Compose rollout`cluster|all` 必须成功完成 catalog-bound 三节点 K3s
install、Head 和 fenced retirement。终态 `release-finalization` 使用显式 needs/result 真值表,要求当前 scope 的 job 全部 `success`,不属于
当前 scope 的 job 必须为 `skipped``failure``cancelled` 或意外运行均失败关闭。
3. 每个部署 evidence artifact 必须同时保留 owner-private、canonical、exact-three-file catalog consumption bundle 和 content-free report。
Local artifact 精确为 consumption 三文件、`edge.json``standalone.json`Cluster artifact 精确为 consumption 三文件、`report.json`
finalizer 下载后重新固定目录 `0700`、文件 `0600` 并拒绝缺失或额外文件。
4. finalizer 不信任 publisher 或部署 job 的瞬时内存。它再次从 discovery ref 解析 immutable catalog,独立执行 Cosign、GitHub provenance、
manifest round-trip 与 ceremony audit,并从自己读取的 bytes 重建 catalog plan/receipt。
5. 新增 `qinglong/release-deployment-readiness-receipt@v1`。它联合审计 finalizer consumption、当前 scope 要求的每个 deployment
consumption bundle 和 report,绑定 release identity、immutable catalog reference、manifest/release-set digest、每份 consumption/report
digest、精确部署族和清理结果。它拒绝 synthetic fixture、不完整报告、catalog 脱离、跨 scope 证据和未清理现场。
6. readiness receipt 必须在 Docker login 和任何 tag mutation 之前生成、复审并单独 attested。只有该 attestation 成功,finalizer 才生成并执行
`qinglong/release-publication-plan@v2`。v2 plan 和 `qinglong/release-publication-closure-receipt@v2` 都绑定 readiness receipt digest、
finalizer consumption digest 和精确部署族,发布 authority 升为 `verified_catalog_bound_deployments`
7. D-350 的 bounded inventory、全量 conflict preflight、exact-tag reuse、absent-tag copy、最终逐 tag 回读和无 CAS/跨仓库事务声明保持不变。
它们只从 catalog publisher 移入唯一的 finalizer,并发生在 readiness attestation 之后。
8. 90 天最终 bundle 保存 finalizer consumption、scope-exact deployment evidence、readiness receipt、publication plan、tag observation 与 closure
receipt。部署 authority 仍是签名并 attested 的 immutable catalog digestmutable tag 与 closure 只表示发布可见性和终态证据。
## 失败与恢复
- catalog 已发布但任一要求的部署门失败:没有最终 image tag mutation。该 catalog 是可复验候选材料,不是 deployment-ready tag authority。
- evidence artifact 缺失、额外、权限过宽、包含 synthetic report 或引用不同 catalogfinalizer 在 registry login 前失败。
- readiness attestation 失败:不发布 tag;同一 protected source tag 可重跑并重新取得现场证据。
- tag promotion 中途 response loss:继续使用 ADR-0442 的 exact-tag reuse,仅补 absent tag;不同 digest 永远冲突失败。
- readiness receipt 是本次部署运行的操作证据,其 digest 可以随新的真实 consumption/report 变化;它不重新定义 release-set 或 immutable
catalog identity,也不伪称跨重跑逐字节确定。
## 部署与资源影响
- Edge、Standalone、低配路由设备和 Cluster 节点不安装 Node、regctl、Cosign 或 GitHub CLI,不新增 watcher、timer、listener、updater、
queue、缓存或常驻进程。
- 不新增 workspace package、生产依赖、数据库、schema、migration、SQL、Kubernetes object、RBAC 或业务副本。
- 增量 CPU、内存、网络和磁盘只发生在短生命周期 release runner。Local scope 不运行 K3s 门,Cluster scope 不运行 Compose 门;`all` 才运行两族。
## 被拒绝的替代方案
### catalog-ready 后继续立即发布最终 tag
拒绝。catalog 完整不等于 scope 要求的部署路径已成功,仍会留下“公开 tag 成功、真实部署失败”的窗口。
### 只依赖 GitHub job result,不闭合部署证据
拒绝。瞬时调度状态不能证明 report 使用了哪个 immutable catalog,也无法让最终 bundle 独立审计。
### 让部署 job 自行发布 tag
拒绝。会把 package write、attestation write 和 registry login 权限扩散到多个高成本 runner,并引入多写者竞争。
### 把完整 token、kubeconfig 或命令 transcript 上传给 finalizer
拒绝。finalizer 只需要 owner-private catalog bundle 和 content-free bounded reportcredential 与现场控制材料必须留在原 job 生命周期内。
## 验证
- readiness contract 覆盖 Local/Cluster/All 精确部署族、缺失/额外/跨 scope 证据、catalog 脱离、synthetic/incomplete/unclean report、
receipt tamper、自摘要重算和 closed CLI
- release workflow 静态门固定 `catalog receipt → scope-exact deployment jobs → finalizer independent consumption → readiness audit/attestation →
tag mutation → closure audit/attestation` 顺序,并拒绝 evidence bundle、needs/result、独立 attestation 或最终 bundle 漂移;
- 完整仓库、产物档位和部署 Gate 的阶段结果记录在 QL-RFC-0001 D-351;首份真实 GHCR deployment-ready finalization 仍须由受保护
`v3` release tag 或受控 release repository 演练产生。
+2 -1
View File
@@ -445,7 +445,8 @@
| [ADR-0439](./ADR-0439-deterministic-private-evidence-receipts-and-release-set-replay.md) | 确定性私有证据收据与 Release-set 重放 | Accepted(首份真实线上重放待实际 release tag | | [ADR-0439](./ADR-0439-deterministic-private-evidence-receipts-and-release-set-replay.md) | 确定性私有证据收据与 Release-set 重放 | Accepted(首份真实线上重放待实际 release tag |
| [ADR-0440](./ADR-0440-release-set-closure-private-evidence-freshness.md) | Release-set 闭合时私有证据 Freshness 重验证 | Accepted(首份真实线上闭合待实际 release tag | | [ADR-0440](./ADR-0440-release-set-closure-private-evidence-freshness.md) | Release-set 闭合时私有证据 Freshness 重验证 | Accepted(首份真实线上闭合待实际 release tag |
| [ADR-0441](./ADR-0441-no-overwrite-release-catalog-discovery-publication.md) | Release Catalog Discovery Tag 无覆盖发布 | Accepted(首份真实 GHCR conflict/reuse 证据待实际 release tag | | [ADR-0441](./ADR-0441-no-overwrite-release-catalog-discovery-publication.md) | Release Catalog Discovery Tag 无覆盖发布 | Accepted(首份真实 GHCR conflict/reuse 证据待实际 release tag |
| [ADR-0442](./ADR-0442-catalog-ready-terminal-release-tag-publication.md) | Catalog-ready 的终态 Release Tag 发布与闭合收据 | Accepted(首份真实 GHCR 部分 promotion/replay 证据待实际 release tag | | [ADR-0442](./ADR-0442-catalog-ready-terminal-release-tag-publication.md) | Catalog-ready 的终态 Release Tag 发布与闭合收据 | Superseded by ADR-0443bounded promotion/closure 机制保留 |
| [ADR-0443](./ADR-0443-deployment-ready-terminal-release-finalization.md) | Deployment-ready 的终态 Release Finalization | Accepted(首份真实 GHCR deployment-ready finalization 待实际 release tag |
## 规则 ## 规则
+29 -7
View File
@@ -36,28 +36,50 @@ cert-manager 三项静态审计摘要。v2 收据不持久化私有 runner 的 w
## 发布流水线内置 Local 与 Cluster 下游门 ## 发布流水线内置 Local 与 Cluster 下游门
durable catalog 建立只表示候选发布材料可独立验证,不再直接授予最终 image tag 发布权。最终
`versionTag/sourceTag` 只能由 `release-finalization` 在当前 scope 的全部下游门成功、deployment readiness receipt 已复审并单独
attested 后写入。catalog 先存在而部署门失败时,不会产生正式 image tag;运维者不得把 discovery tag 或未闭合 catalog 当作
deployment-ready 版本公告。
`local|all` scope 在 durable catalog 发布后启动独立 `release-catalog-local-deployment-live`。它与 publisher 权限隔离,从公开 catalog `local|all` scope 在 durable catalog 发布后启动独立 `release-catalog-local-deployment-live`。它与 publisher 权限隔离,从公开 catalog
重新完成发现、Cosign/GitHub provenance 验证和 three-file bundle audit,随后物化唯一 Local v2 selection。该 selection 不是只做 JSON 重新完成发现、Cosign/GitHub provenance 验证和 three-file bundle audit,随后物化唯一 Local v2 selection。该 selection 不是只做 JSON
检查:同一个 immutable Local image 与 selection 会依次进入 Edge、Standalone 的正式 Compose rollout、SQLite backup/restore、evidence 检查:同一个 immutable Local image 与 selection 会依次进入 Edge、Standalone 的正式 Compose rollout、SQLite backup/restore、evidence
collection 和 graceful stop。两个 content-free report 必须绑定同一 release-set、catalog manifest、consumption report 与 selection digest collection 和 graceful stop。两个 content-free report 必须绑定同一 release-set、catalog manifest、consumption report 与 selection digest
任一 Profile 失败都会阻断 Local release。 任一 Profile 失败都会阻断 Local release。
该 job 只有 `contents|packages|attestations:read`,不执行 Docker login、签名、tag promotion 或 catalog mutation。regctl、Cosign、GitHub CLI、 该 job 只有 `contents|packages|attestations:read`,不执行 Docker login、签名、tag promotion 或 catalog mutation。成功 artifact 精确保存
Node workspace、token 和原始 bundle 仅存在于短生命周期 release runner;路由设备/NAS/单机用户不安装这些发布工具,也不增加常驻 updater owner-private catalog consumption 三文件、`edge.json``standalone.json`,供终态 finalizer 复审;token 与命令材料不上传。regctl、Cosign
listener 或 timer。第一份真实证据仍必须由受保护 release tag 产生;PR fixture 只验证失败关闭与产品 rollout 路径。 GitHub CLI、Node workspace 和原始 bundle 仅存在于短生命周期 release runner;路由设备/NAS/单机用户不安装这些发布工具,也不增加常驻
updater、listener 或 timer。第一份真实证据仍必须由受保护 release tag 产生;PR fixture 只验证失败关闭与产品 rollout 路径。
## 发布流水线内置 Cluster 下游门 ## 发布流水线内置 Cluster 下游门
`ql3-image-release.yml` 在 durable catalog、签名、provenancereceipt 和 90 天便利 bundle 全部发布后,才启动独立 `ql3-image-release.yml` 在 durable catalog、签名、provenancereceipt 全部发布后,才启动独立
`release-catalog-deployment-live`。该 job 只处理 `cluster|all`Local-only 发布不会启动 K3s。它没有 publisher 的 package/attestation `release-catalog-deployment-live`。该 job 只处理 `cluster|all`Local-only 发布不会启动 K3s。它没有 publisher 的 package/attestation
写权限或 registry 登录态,而是按下文工作站协议重新消费公开 catalog,离线重建 deployment lock,并在隔离三节点 K3s 上完成 install、 写权限或 registry 登录态,而是按下文工作站协议重新消费公开 catalog,离线重建 deployment lock,并在隔离三节点 K3s 上完成 install、
Head commit、一个显式 ConfigMap 的 UID/resourceVersion 围栏退役和两个 receipt audit。任何公开读取、exact workflow identity、source Head commit、一个显式 ConfigMap 的 UID/resourceVersion 围栏退役和两个 receipt audit。任何公开读取、exact workflow identity、source
tag/revision、digest、scope、角色闭包、server-side apply/Head 或清理失败都会让 Cluster release 失败。 tag/revision、digest、scope、角色闭包、server-side apply/Head 或清理失败都会让 Cluster release 失败。
该门的四个业务 Deployment 都为零副本:它证明真实 catalog image authority 已进入目标 API、lock、Head 与 receipt 闭包,不重复下载和启动 该门的四个业务 Deployment 都为零副本:它证明真实 catalog image authority 已进入目标 API、lock、Head 与 receipt 闭包,不重复下载和启动
完整业务数据面。应用启动、数据库、Secret、Worker 与 AI lifecycle 继续由各自 live/release gate 负责。成功 artifact 只保存 content-free 完整业务数据面。应用启动、数据库、Secret、Worker 与 AI lifecycle 继续由各自 live/release gate 负责。成功 artifact 精确保存
JSON evidenceconsumption bundle、token、kubeconfig command 文件不上传。首份真实成功记录必须来自实际受保护 release tag,普通 PR 中的 owner-private catalog consumption 三文件和 `report.json`token、kubeconfig command 文件不上传。首份真实成功记录必须来自实际受保护
synthetic live fixture 不能替代它。 release tag,普通 PR 中的 synthetic live fixture 不能替代它。
## Deployment-ready 终态发布
`release-finalization` 是唯一具有 `packages:write``attestations:write` 的最终 tag 写者。它使用严格 scope 真值表:`local` 要求 Local
成功且 Cluster 跳过,`cluster` 要求 Cluster 成功且 Local 跳过,`all` 要求两者都成功。下载的 Local evidence 必须精确为五个文件,Cluster
evidence 必须精确为四个文件;目录重新设为 `0700`、文件为 `0600`,缺失、额外、synthetic、未完成或未清理报告均失败关闭。
finalizer 随后独立重新消费公开 immutable catalog,而不是复用 publisher 的临时输出。`qinglong/release-deployment-readiness-receipt@v1`
把 finalizer consumption、scope 要求的每个 deployment consumption/report、immutable catalog、release-set 和清理结果闭合为一份
self-digest receipt。只有该 receipt 完成本地 audit 和独立 attestation`qinglong/release-publication-plan@v2` 才能进入 bounded tag
inventory、全量冲突预检与 promotion`qinglong/release-publication-closure-receipt@v2` 最终绑定同一 readiness。90 天 bundle 包含 finalizer
consumption、部署 evidence、readiness、plan、tag observation 和 closure,能够离线复查“哪个 catalog、哪些部署族、哪些终态 tag”。
readiness 是本次 release workflow 的操作证据,不是新的镜像内容 identity。同一 protected source tag 因失败而重跑时,新的真实部署报告可使
readiness digest 不同;release-set 与 immutable catalog digest 仍必须相同,既有 exact tag 只能复用,不同 digest 继续失败。OCI registry
没有跨 repository 事务或 tag CAS,因此 closure 证明的是观测到的终态,不是原子提交。
## 选择 scope ## 选择 scope
@@ -257,9 +257,10 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
'Attest durable release-catalog provenance', 'Attest durable release-catalog provenance',
'Verify the durable catalog and create its immutable receipt', 'Verify the durable catalog and create its immutable receipt',
'Attest the immutable release-catalog receipt', 'Attest the immutable release-catalog receipt',
'Promote final tags only after the catalog receipt is attested', 'Attest deployment readiness before any final tag mutation',
'Close and audit the final public tag set', 'Promote final tags only after every required deployment gate',
'Attest the immutable release publication closure receipt', 'Close and audit the deployment-ready public tag set',
'Attest the deployment-ready release publication closure receipt',
], ],
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT', 'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
); );
+142 -38
View File
@@ -274,7 +274,7 @@ function auditClusterImageCiWorkflow(
); );
requirePattern( requirePattern(
source, source,
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleasePublicationClosureContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/, /node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseDeploymentReadinessContract\.test\.cjs[\s\S]*test\/back\/ql3ReleasePublicationClosureContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/,
'cluster image CI must run SBOM, candidate, private evidence receipt, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory', 'cluster image CI must run SBOM, candidate, private evidence receipt, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory',
); );
requirePattern( requirePattern(
@@ -414,6 +414,7 @@ function auditReleaseWorkflow(source) {
workflow?.jobs?.['release-catalog-local-deployment-live']; workflow?.jobs?.['release-catalog-local-deployment-live'];
const catalogDeploymentJob = const catalogDeploymentJob =
workflow?.jobs?.['release-catalog-deployment-live']; workflow?.jobs?.['release-catalog-deployment-live'];
const finalizationJob = workflow?.jobs?.['release-finalization'];
if ( if (
publishJob?.strategy?.matrix?.include !== publishJob?.strategy?.matrix?.include !==
'${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}' '${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}'
@@ -487,6 +488,14 @@ function auditReleaseWorkflow(source) {
contents: 'read', contents: 'read',
packages: 'read', packages: 'read',
attestations: 'read', attestations: 'read',
}) ||
JSON.stringify(finalizationJob?.permissions) !==
JSON.stringify({
contents: 'read',
packages: 'write',
'id-token': 'write',
attestations: 'write',
'artifact-metadata': 'write',
}) })
) { ) {
throw new Error( throw new Error(
@@ -712,7 +721,7 @@ function auditReleaseWorkflow(source) {
const releaseSetSteps = releaseSetJob?.steps; const releaseSetSteps = releaseSetJob?.steps;
if ( if (
!Array.isArray(releaseSetSteps) || !Array.isArray(releaseSetSteps) ||
releaseSetSteps.length !== 19 || releaseSetSteps.length !== 15 ||
releaseSetSteps[0]?.uses !== releaseSetSteps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' || 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
releaseSetSteps[0]?.with?.['persist-credentials'] !== false || releaseSetSteps[0]?.with?.['persist-credentials'] !== false ||
@@ -793,27 +802,11 @@ function auditReleaseWorkflow(source) {
JSON.stringify({ JSON.stringify({
'subject-path': '${{ steps.catalog-receipt.outputs.receipt }}', 'subject-path': '${{ steps.catalog-receipt.outputs.receipt }}',
}) || }) ||
releaseSetSteps[14]?.id !== 'final-publication' || releaseSetSteps[14]?.uses !==
!/plan="\$\{BUNDLE\}\/qinglong3-release-publication-plan-[^\n]+[\s\S]*observations="\$\{BUNDLE\}\/qinglong3-release-publication-tag-observation-[^\n]+[\s\S]*receipt="\$\{BUNDLE\}\/qinglong3-release-publication-closure-receipt-[^\n]+[\s\S]*ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=plan[\s\S]*--release-set="\$\{RELEASE_SET\}"[\s\S]*--catalog-plan="\$\{CATALOG_PLAN\}"[\s\S]*--catalog-manifest="\$\{CATALOG_MANIFEST\}"[\s\S]*--catalog-manifest-digest="\$\{CATALOG_MANIFEST_DIGEST\}"[\s\S]*--catalog-receipt="\$\{CATALOG_RECEIPT\}"[\s\S]*GITHUB_OUTPUT/.test(
releaseSetSteps[14]?.run ?? '',
) ||
!/createPublicationTagObservation[\s\S]*maxInventoryBytes = 1024 \* 1024[\s\S]*tagPattern[\s\S]*for \(const image of plan\.images\)[\s\S]*image\.immutableReference[\s\S]*'tag',[\s\S]*'ls',[\s\S]*image\.registryRepository[\s\S]*inventoryContents\.endsWith\('\\n'\)[\s\S]*new Set\(inventory\)\.size !== inventory\.length[\s\S]*release tag already points at another digest[\s\S]*for \(const state of states\)[\s\S]*'image',[\s\S]*'copy',[\s\S]*state\.image\.immutableReference[\s\S]*promoted tag does not resolve to the release-set digest[\s\S]*createPublicationTagObservation\(plan, observedTags\)[\s\S]*fs\.openSync\(process\.env\.TAG_OBSERVATIONS, 'wx', 0o600\)/.test(
releaseSetSteps[15]?.run ?? '',
) ||
!/ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=close[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--observations="\$\{TAG_OBSERVATIONS\}"[\s\S]*--output="\$\{CLOSURE_RECEIPT\}"[\s\S]*ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=audit[\s\S]*--receipt="\$\{CLOSURE_RECEIPT\}"/.test(
releaseSetSteps[16]?.run ?? '',
) ||
releaseSetSteps[17]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(releaseSetSteps[17]?.with) !==
JSON.stringify({
'subject-path': '${{ steps.final-publication.outputs.receipt }}',
}) ||
releaseSetSteps[18]?.uses !==
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' || 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
JSON.stringify(releaseSetSteps[18]?.with) !== JSON.stringify(releaseSetSteps[14]?.with) !==
JSON.stringify({ JSON.stringify({
name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}', name: 'ql3-release-catalog-publisher-${{ github.run_id }}-${{ github.run_attempt }}',
path: '${{ steps.release-set.outputs.bundle }}', path: '${{ steps.release-set.outputs.bundle }}',
'if-no-files-found': 'error', 'if-no-files-found': 'error',
'retention-days': 90, 'retention-days': 90,
@@ -823,7 +816,7 @@ function auditReleaseWorkflow(source) {
}) })
) { ) {
throw new Error( throw new Error(
'release-set job must download only same-run records, independently inspect, durably publish and attest one no-overwrite deployment lock bundle', 'release-set job must download only same-run records, independently inspect and durably publish one attested immutable catalog without final tag authority',
); );
} }
const localCatalogDeploymentSteps = localCatalogDeploymentJob?.steps; const localCatalogDeploymentSteps = localCatalogDeploymentJob?.steps;
@@ -850,7 +843,7 @@ function auditReleaseWorkflow(source) {
localCatalogDeploymentSteps[4]?.uses !== localCatalogDeploymentSteps[4]?.uses !==
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' || 'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
localCatalogDeploymentSteps[5]?.id !== 'local-catalog-consumption' || localCatalogDeploymentSteps[5]?.id !== 'local-catalog-consumption' ||
!/install -d -m 0700[\s\S]*install -m 0600 \/dev\/null[\s\S]*printf '%s' "\$\{GH_TOKEN\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=create[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--output-directory="\$\{bundle\}"[\s\S]*--github-token-file="\$\{token\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=audit[\s\S]*rm -f "\$\{token\}"/u.test( !/private_root="\$\{RUNNER_TEMP\}\/ql3-release-catalog-local-deployment"[\s\S]*bundle="\$\{private_root\}\/consumption"[\s\S]*install -d -m 0700[\s\S]*install -m 0600 \/dev\/null[\s\S]*printf '%s' "\$\{GH_TOKEN\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=create[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--output-directory="\$\{bundle\}"[\s\S]*--github-token-file="\$\{token\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=audit[\s\S]*rm -f "\$\{token\}"/u.test(
localCatalogDeploymentSteps[5]?.run ?? '', localCatalogDeploymentSteps[5]?.run ?? '',
) || ) ||
localCatalogDeploymentSteps[6]?.id !== 'local-selection' || localCatalogDeploymentSteps[6]?.id !== 'local-selection' ||
@@ -915,7 +908,7 @@ function auditReleaseWorkflow(source) {
catalogDeploymentSteps[5]?.run ?? '', catalogDeploymentSteps[5]?.run ?? '',
) || ) ||
catalogDeploymentSteps[6]?.id !== 'catalog-consumption' || catalogDeploymentSteps[6]?.id !== 'catalog-consumption' ||
!/install -d -m 0700[\s\S]*install -m 0600 \/dev\/null[\s\S]*printf '%s' "\$\{GH_TOKEN\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=create[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--output-directory="\$\{bundle\}"[\s\S]*--github-token-file="\$\{token\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=audit[\s\S]*rm -f "\$\{token\}"/u.test( !/private_root="\$\{RUNNER_TEMP\}\/ql3-release-catalog-deployment"[\s\S]*bundle="\$\{private_root\}\/consumption"[\s\S]*install -d -m 0700[\s\S]*install -m 0600 \/dev\/null[\s\S]*printf '%s' "\$\{GH_TOKEN\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=create[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--output-directory="\$\{bundle\}"[\s\S]*--github-token-file="\$\{token\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=audit[\s\S]*rm -f "\$\{token\}"/u.test(
catalogDeploymentSteps[6]?.run ?? '', catalogDeploymentSteps[6]?.run ?? '',
) || ) ||
!/docker pull[\s\S]*rancher\/k3s@sha256:71abd3a56f57884c62732e0e0d87606052cb5f8555b7db7e8e33c04570b8175c[\s\S]*docker tag[\s\S]*rancher\/k3s:v1\.34\.3-k3s1/u.test( !/docker pull[\s\S]*rancher\/k3s@sha256:71abd3a56f57884c62732e0e0d87606052cb5f8555b7db7e8e33c04570b8175c[\s\S]*docker tag[\s\S]*rancher\/k3s:v1\.34\.3-k3s1/u.test(
@@ -949,7 +942,7 @@ function auditReleaseWorkflow(source) {
JSON.stringify(catalogDeploymentSteps[10]?.with) !== JSON.stringify(catalogDeploymentSteps[10]?.with) !==
JSON.stringify({ JSON.stringify({
name: 'ql3-release-catalog-deployment-${{ github.run_id }}-${{ github.run_attempt }}', name: 'ql3-release-catalog-deployment-${{ github.run_id }}-${{ github.run_attempt }}',
path: '${{ runner.temp }}/ql3-release-catalog-deployment/report.json', path: '${{ runner.temp }}/ql3-release-catalog-deployment',
'if-no-files-found': 'warn', 'if-no-files-found': 'warn',
'retention-days': 90, 'retention-days': 90,
'compression-level': 9, 'compression-level': 9,
@@ -967,6 +960,102 @@ function auditReleaseWorkflow(source) {
'cluster release must read the newly published immutable catalog, reconstruct its deployment lock and prove install plus fenced retirement on isolated K3s without publication authority', 'cluster release must read the newly published immutable catalog, reconstruct its deployment lock and prove install plus fenced retirement on isolated K3s without publication authority',
); );
} }
const finalizationSteps = finalizationJob?.steps;
if (
JSON.stringify(finalizationJob?.needs) !==
JSON.stringify([
'release-set',
'release-catalog-local-deployment-live',
'release-catalog-deployment-live',
]) ||
!/always\(\)[\s\S]*needs\.release-set\.result == 'success'[\s\S]*inputs\.release_scope == 'local'[\s\S]*release-catalog-local-deployment-live\.result == 'success'[\s\S]*release-catalog-deployment-live\.result == 'skipped'[\s\S]*inputs\.release_scope == 'cluster'[\s\S]*release-catalog-local-deployment-live\.result == 'skipped'[\s\S]*release-catalog-deployment-live\.result == 'success'[\s\S]*inputs\.release_scope == 'all'[\s\S]*release-catalog-local-deployment-live\.result == 'success'[\s\S]*release-catalog-deployment-live\.result == 'success'/u.test(
finalizationJob?.if ?? '',
) ||
finalizationJob?.['runs-on'] !== 'ubuntu-24.04' ||
finalizationJob?.['timeout-minutes'] !== 30 ||
!Array.isArray(finalizationSteps) ||
finalizationSteps.length !== 16 ||
finalizationSteps[0]?.uses !==
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
finalizationSteps[0]?.with?.['persist-credentials'] !== false ||
finalizationSteps[1]?.uses !==
'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' ||
finalizationSteps[1]?.with?.['node-version'] !== '24.18.0' ||
!/corepack prepare pnpm@8\.3\.1 --activate[\s\S]*pnpm install --frozen-lockfile --ignore-scripts/u.test(
finalizationSteps[2]?.run ?? '',
) ||
finalizationSteps[3]?.if !== "inputs.release_scope != 'cluster'" ||
finalizationSteps[3]?.uses !==
'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' ||
JSON.stringify(finalizationSteps[3]?.with) !==
JSON.stringify({
name: 'ql3-release-catalog-local-deployment-${{ github.run_id }}-${{ github.run_attempt }}',
path: '${{ runner.temp }}/ql3-release-finalization/deployment-evidence/local',
}) ||
finalizationSteps[4]?.if !== "inputs.release_scope != 'local'" ||
finalizationSteps[4]?.uses !==
'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' ||
JSON.stringify(finalizationSteps[4]?.with) !==
JSON.stringify({
name: 'ql3-release-catalog-deployment-${{ github.run_id }}-${{ github.run_attempt }}',
path: '${{ runner.temp }}/ql3-release-finalization/deployment-evidence/cluster',
}) ||
!/chmod 0700 "\$\{directory\}"[\s\S]*find "\$\{FINALIZATION_ROOT\}" -type d[\s\S]*chmod 0600 "\$\{file\}"[\s\S]*find "\$\{FINALIZATION_ROOT\}" -type f[\s\S]*local\/edge\.json[\s\S]*local\/standalone\.json[\s\S]*cluster\/report\.json/u.test(
finalizationSteps[5]?.run ?? '',
) ||
!/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/u.test(
finalizationSteps[6]?.run ?? '',
) ||
finalizationSteps[7]?.uses !==
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
finalizationSteps[8]?.id !== 'finalizer-consumption' ||
!/ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=create[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--output-directory="\$\{bundle\}"[\s\S]*--github-token-file="\$\{token\}"[\s\S]*--mode=audit[\s\S]*rm -f "\$\{token\}"/u.test(
finalizationSteps[8]?.run ?? '',
) ||
finalizationSteps[9]?.id !== 'final-publication' ||
!/observations="\$\{FINALIZATION_ROOT\}\/qinglong3-release-publication-tag-observation-[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=plan[\s\S]*--release-set="\$\{release_set\}"[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=receipt[\s\S]*readiness_args=\([\s\S]*--finalizer-consumption-bundle="\$\{FINALIZER_CONSUMPTION\}"[\s\S]*--local-consumption-bundle=[\s\S]*--edge-report=[\s\S]*--standalone-report=[\s\S]*--cluster-consumption-bundle=[\s\S]*--cluster-report=[\s\S]*ql3-release-deployment-readiness-contract\.cjs[\s\S]*--mode=create[\s\S]*--output="\$\{readiness\}"[\s\S]*ql3-release-deployment-readiness-contract\.cjs[\s\S]*--mode=audit[\s\S]*--receipt="\$\{readiness\}"[\s\S]*ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=plan[\s\S]*--deployment-readiness="\$\{readiness\}"[\s\S]*GITHUB_OUTPUT/u.test(
finalizationSteps[9]?.run ?? '',
) ||
finalizationSteps[10]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(finalizationSteps[10]?.with) !==
JSON.stringify({
'subject-path': '${{ steps.final-publication.outputs.readiness }}',
}) ||
finalizationSteps[11]?.uses !==
'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' ||
!/createPublicationTagObservation[\s\S]*maxInventoryBytes = 1024 \* 1024[\s\S]*tagPattern[\s\S]*for \(const image of plan\.images\)[\s\S]*image\.immutableReference[\s\S]*'tag',[\s\S]*'ls',[\s\S]*image\.registryRepository[\s\S]*inventoryContents\.endsWith\('\\n'\)[\s\S]*new Set\(inventory\)\.size !== inventory\.length[\s\S]*release tag already points at another digest[\s\S]*for \(const state of states\)[\s\S]*'image',[\s\S]*'copy',[\s\S]*state\.image\.immutableReference[\s\S]*promoted tag does not resolve to the release-set digest[\s\S]*createPublicationTagObservation\(plan, observedTags\)[\s\S]*fs\.openSync\(process\.env\.TAG_OBSERVATIONS, 'wx', 0o600\)/u.test(
finalizationSteps[12]?.run ?? '',
) ||
!/ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=close[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--observations="\$\{TAG_OBSERVATIONS\}"[\s\S]*--output="\$\{CLOSURE_RECEIPT\}"[\s\S]*--mode=audit[\s\S]*--receipt="\$\{CLOSURE_RECEIPT\}"/u.test(
finalizationSteps[13]?.run ?? '',
) ||
finalizationSteps[14]?.uses !==
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
JSON.stringify(finalizationSteps[14]?.with) !==
JSON.stringify({
'subject-path': '${{ steps.final-publication.outputs.closure }}',
}) ||
finalizationSteps[15]?.uses !==
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
JSON.stringify(finalizationSteps[15]?.with) !==
JSON.stringify({
name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}',
path: '${{ steps.final-publication.outputs.root }}',
'if-no-files-found': 'error',
'retention-days': 90,
'compression-level': 0,
overwrite: false,
'include-hidden-files': false,
}) ||
finalizationSteps
.slice(0, 12)
.some((step) => /\bimage copy\b/u.test(step.run ?? ''))
) {
throw new Error(
'release finalization must independently verify exact scope deployment evidence, attest readiness and only then publish and close final tags',
);
}
if ( if (
!Array.isArray(evidenceJob?.steps) || !Array.isArray(evidenceJob?.steps) ||
evidenceJob.steps.length !== 4 || evidenceJob.steps.length !== 4 ||
@@ -1097,20 +1186,20 @@ function auditReleaseWorkflow(source) {
requireOccurrences( requireOccurrences(
source, source,
/uses: actions\/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6/g, /uses: actions\/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6/g,
8, 9,
'all release jobs must pin the reviewed immutable checkout action', 'all release jobs must pin the reviewed immutable checkout action',
); );
requireOccurrences( requireOccurrences(
source, source,
/uses: actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/g, /uses: actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/g,
8, 9,
'all release jobs must pin the reviewed immutable Node setup action', 'all release jobs must pin the reviewed immutable Node setup action',
); );
requireOccurrences( requireOccurrences(
source, source,
/uses: sigstore\/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4\.1\.2/g, /uses: sigstore\/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4\.1\.2/g,
4, 5,
'image publisher, catalog publisher and both catalog consumers must pin the reviewed Cosign installer', 'image publisher, catalog publisher, both deployment consumers and the finalizer must pin the reviewed Cosign installer',
); );
requirePattern( requirePattern(
source, source,
@@ -1150,8 +1239,8 @@ function auditReleaseWorkflow(source) {
requireExactOccurrences( requireExactOccurrences(
source, source,
/c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467/g, /c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467/g,
4, 5,
'image publisher, release-set publisher and both catalog consumers must checksum-pin the exact regctl OCI copier and reader', 'image publisher, release-set publisher, both deployment consumers and the finalizer must checksum-pin the exact regctl OCI copier and reader',
); );
requirePattern( requirePattern(
source, source,
@@ -1171,8 +1260,8 @@ function auditReleaseWorkflow(source) {
requireOccurrences( requireOccurrences(
source, source,
/uses: actions\/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4/g, /uses: actions\/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4/g,
7, 9,
'release workflow must create four image attestations plus release-set, durable catalog and receipt provenance', 'release workflow must create four image attestations plus release-set, durable catalog, catalog receipt, deployment readiness and final closure provenance',
); );
requireOccurrences( requireOccurrences(
source, source,
@@ -1266,8 +1355,8 @@ function auditReleaseWorkflow(source) {
); );
requirePattern( requirePattern(
source, source,
/release-set:\s+name: Close and publish the complete deployment release set[\s\S]*needs:[\s\S]*- publish[\s\S]*name: Attest the complete release-set file provenance[\s\S]*name: Publish and round-trip the durable OCI release catalog[\s\S]*name: Keylessly sign the immutable release-catalog digest[\s\S]*name: Attest durable release-catalog provenance[\s\S]*name: Verify the durable catalog and create its immutable receipt[\s\S]*name: Attest the immutable release-catalog receipt[\s\S]*name: Materialize the catalog-authorized final tag publication plan[\s\S]*name: Promote final tags only after the catalog receipt is attested[\s\S]*name: Close and audit the final public tag set[\s\S]*name: Attest the immutable release publication closure receipt[\s\S]*name: Publish the deployment digest lock/, /release-set:\s+name: Close the release set and publish its immutable catalog[\s\S]*needs:[\s\S]*- publish[\s\S]*name: Attest the complete release-set file provenance[\s\S]*name: Publish and round-trip the durable OCI release catalog[\s\S]*name: Keylessly sign the immutable release-catalog digest[\s\S]*name: Attest durable release-catalog provenance[\s\S]*name: Verify the durable catalog and create its immutable receipt[\s\S]*name: Attest the immutable release-catalog receipt[\s\S]*name: Upload same-run catalog publisher evidence[\s\S]*release-catalog-local-deployment-live:[\s\S]*name: Prove catalog-bound Edge and Standalone rollout[\s\S]*release-catalog-deployment-live:[\s\S]*name: Prove catalog-bound install and fenced retirement on three K3s nodes[\s\S]*release-finalization:[\s\S]*name: Attest deployment readiness before any final tag mutation[\s\S]*name: Promote final tags only after every required deployment gate[\s\S]*name: Close and audit the deployment-ready public tag set[\s\S]*name: Attest the deployment-ready release publication closure receipt[\s\S]*name: Upload the final deployment-ready release bundle/,
'release tags and the final closure receipt must be published only after the durable catalog is verified and its receipt is attested', 'release tags and the final closure receipt must be published only after the durable catalog and every required catalog-bound deployment gate are verified',
); );
return { return {
trigger: 'explicit protected v3 tag dispatch', trigger: 'explicit protected v3 tag dispatch',
@@ -1324,6 +1413,7 @@ function auditReleaseWorkflow(source) {
tagAfterVerification: true, tagAfterVerification: true,
tagAfterCompleteReleaseSet: true, tagAfterCompleteReleaseSet: true,
tagAfterVerifiedCatalog: true, tagAfterVerifiedCatalog: true,
tagAfterRequiredDeploymentGates: true,
boundedRepositoryTagInventory: true, boundedRepositoryTagInventory: true,
allTagConflictsCheckedBeforeMutation: true, allTagConflictsCheckedBeforeMutation: true,
responseLossRecovery: 'reuse_exact_digest_only', responseLossRecovery: 'reuse_exact_digest_only',
@@ -1336,7 +1426,7 @@ function auditReleaseWorkflow(source) {
privateEvidenceFreshnessRevalidatedAtClosure: true, privateEvidenceFreshnessRevalidatedAtClosure: true,
exactScopeClosure: true, exactScopeClosure: true,
standaloneInspection: true, standaloneInspection: true,
tagPromotionAuthority: 'verified_immutable_catalog', tagPromotionAuthority: 'verified_catalog_bound_deployments',
fileProvenanceAttested: true, fileProvenanceAttested: true,
artifactRetentionDays: 90, artifactRetentionDays: 90,
crossRepositoryAtomicity: false, crossRepositoryAtomicity: false,
@@ -1364,10 +1454,11 @@ function auditReleaseWorkflow(source) {
receiptAttested: true, receiptAttested: true,
}, },
finalPublicationClosure: { finalPublicationClosure: {
planSchema: 'qinglong/release-publication-plan@v1', planSchema: 'qinglong/release-publication-plan@v2',
tagObservationSchema: 'qinglong/release-publication-tag-observation@v1', tagObservationSchema: 'qinglong/release-publication-tag-observation@v1',
receiptSchema: 'qinglong/release-publication-closure-receipt@v1', receiptSchema: 'qinglong/release-publication-closure-receipt@v2',
catalogReadyBeforeTagMutation: true, catalogReadyBeforeTagMutation: true,
deploymentReadyBeforeTagMutation: true,
allTagsExactDigest: true, allTagsExactDigest: true,
tagsPerImage: 2, tagsPerImage: 2,
conflictPolicy: 'fail_closed_before_any_tag_mutation', conflictPolicy: 'fail_closed_before_any_tag_mutation',
@@ -1376,6 +1467,16 @@ function auditReleaseWorkflow(source) {
registryTagCas: false, registryTagCas: false,
receiptAttested: true, receiptAttested: true,
}, },
deploymentReadiness: {
receiptSchema: 'qinglong/release-deployment-readiness-receipt@v1',
scopes: ['local', 'cluster', 'all'],
localProfiles: ['edge', 'standalone'],
clusterNodes: 3,
independentFinalizerCatalogConsumption: true,
exactEvidenceBytesAudited: true,
jobResultOnlyAuthority: false,
receiptAttestedBeforeTagMutation: true,
},
catalogDeploymentGate: { catalogDeploymentGate: {
scopes: ['cluster', 'all'], scopes: ['cluster', 'all'],
catalogAuthority: 'immutable_digest_after_public_consumption', catalogAuthority: 'immutable_digest_after_public_consumption',
@@ -1384,6 +1485,7 @@ function auditReleaseWorkflow(source) {
installReceiptAudited: true, installReceiptAudited: true,
fencedRetirementReceiptAudited: true, fencedRetirementReceiptAudited: true,
publicationAuthority: false, publicationAuthority: false,
requiredForFinalization: true,
}, },
localCatalogDeploymentGate: { localCatalogDeploymentGate: {
scopes: ['local', 'all'], scopes: ['local', 'all'],
@@ -1393,6 +1495,7 @@ function auditReleaseWorkflow(source) {
rolloutReceiptAudited: true, rolloutReceiptAudited: true,
gracefulCleanup: true, gracefulCleanup: true,
publicationAuthority: false, publicationAuthority: false,
requiredForFinalization: true,
}, },
localRolloutPreflight: true, localRolloutPreflight: true,
localRolloutApply: true, localRolloutApply: true,
@@ -1408,6 +1511,7 @@ function auditReleaseWorkflow(source) {
'catalog-consumption', 'catalog-consumption',
'catalog-bound-local-compose-deployment', 'catalog-bound-local-compose-deployment',
'catalog-bound-k3s-deployment', 'catalog-bound-k3s-deployment',
'deployment-readiness',
'release-tags', 'release-tags',
'release-publication-closure', 'release-publication-closure',
], ],
@@ -0,0 +1,754 @@
#!/usr/bin/env node
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const {
SCHEMA: CATALOG_CONSUMPTION_SCHEMA,
auditCeremonyBundle,
} = require('./ql3-release-catalog-consumption-ceremony.cjs');
const SCHEMA = 'qinglong/release-deployment-readiness-receipt@v1';
const MAX_REPORT_BYTES = 1024 * 1024;
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
const REVISION_PATTERN = /^[a-f0-9]{40}$/u;
const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/u;
const REPOSITORY_PATTERN =
/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u;
const LOCAL_REPORT_KEYS = Object.freeze([
'schemaVersion',
'profile',
'generation',
'exactRepoDigest',
'composeMerge',
'rolloutActive',
'durableReceipt',
'sqliteWriteContract',
'sqliteBackup',
'sqliteWriteObservation',
'sqliteRestorePrepared',
'sqliteRestoreCommitted',
'sqliteRestoreRolloutRecovered',
'sqliteRestoreReplayUnchanged',
'sqliteEvidenceCollected',
'sqliteCollectedRolloutReplayUnchanged',
'gracefulCleanup',
'releaseAuthority',
'compatible',
]);
const LOCAL_AUTHORITY_KEYS = Object.freeze([
'mode',
'sourceRevision',
'sourceRef',
'scope',
'releaseSetDigest',
'catalogManifestDigest',
'catalogConsumptionDigest',
'selectionDigest',
]);
const CLUSTER_REPORT_KEYS = Object.freeze([
'schemaVersion',
'schema',
'kubernetes',
'deployment',
'releaseAuthority',
'preflightDigest',
'receiptDigest',
'receiptAuditCompatible',
'retirement',
'serverSideDryRun',
'serverSideApply',
'convergenceRead',
'deploymentHeadCas',
'resourceInventoryClosed',
'crossResourceAtomicity',
'cleanupComplete',
]);
const CLUSTER_AUTHORITY_KEYS = Object.freeze([
'mode',
'version',
'sourceRevision',
'sourceRef',
'scope',
'releaseSetDigest',
'catalogManifestDigest',
'catalogConsumptionDigest',
'immutableReference',
]);
const VERIFICATION = Object.freeze({
finalizerCatalogConsumption: 'independently_verified',
deploymentEvidence: 'scope_exact_catalog_bound_live_reports',
reportEncoding: 'bounded_canonical_json',
reportBytesBound: true,
syntheticFixturesAccepted: false,
jobResultOnlyAuthority: false,
tagMutation: false,
});
class QingLong3ReleaseDeploymentReadinessError extends Error {
constructor(message) {
super(`QingLong 3 release deployment readiness failed: ${message}`);
this.name = 'QingLong3ReleaseDeploymentReadinessError';
}
}
function fail(message) {
throw new QingLong3ReleaseDeploymentReadinessError(message);
}
function sha256(value) {
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
}
function canonicalJson(value) {
return `${JSON.stringify(value)}\n`;
}
function exactKeys(value, expected) {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
JSON.stringify(Object.keys(value)) === JSON.stringify(expected)
);
}
function releaseFromIdentity(identity) {
return Object.freeze({
version: identity.version,
sourceRevision: identity.sourceRevision,
sourceRef: identity.sourceRef,
scope: identity.releaseScope,
});
}
function validateIdentity(identity) {
if (
identity === null ||
typeof identity !== 'object' ||
typeof identity.version !== 'string' ||
identity.version.length < 1 ||
identity.version.length > 64 ||
!REVISION_PATTERN.test(identity.sourceRevision || '') ||
identity.sourceRef !== `refs/tags/v${identity.version}` ||
!['local', 'cluster', 'all'].includes(identity.releaseScope) ||
!OWNER_PATTERN.test(identity.repositoryOwner || '') ||
!REPOSITORY_PATTERN.test(identity.sourceRepository || '') ||
!identity.sourceRepository.startsWith(`${identity.repositoryOwner}/`)
) {
fail('release identity is invalid');
}
return releaseFromIdentity(identity);
}
function requiredFamilies(scope) {
return scope === 'local'
? ['local']
: scope === 'cluster'
? ['cluster']
: ['local', 'cluster'];
}
function validateConsumption(consumption, identity, label) {
const expectedImmutable = `ghcr.io/${identity.repositoryOwner}/qinglong3-release-catalog@${consumption?.catalogManifestDigest}`;
if (
consumption === null ||
typeof consumption !== 'object' ||
consumption.compatible !== true ||
consumption.releaseScope !== identity.releaseScope ||
consumption.sourceRepository !== identity.sourceRepository ||
consumption.workflowIdentity !==
`https://github.com/${identity.sourceRepository}/.github/workflows/ql3-image-release.yml@${identity.sourceRef}` ||
!DIGEST_PATTERN.test(consumption.releaseSetDigest || '') ||
!DIGEST_PATTERN.test(consumption.catalogManifestDigest || '') ||
consumption.immutableReference !== expectedImmutable ||
consumption.discoveryTagAuthority !== 'none' ||
consumption.externalToolResultsReplayed !== false ||
consumption.deploymentMutation !== false ||
!DIGEST_PATTERN.test(consumption.contentDigest || '') ||
consumption.releaseSet?.release?.version !== identity.version ||
consumption.releaseSet?.release?.sourceRevision !==
identity.sourceRevision ||
consumption.releaseSet?.release?.sourceRef !== identity.sourceRef ||
consumption.releaseSet?.release?.scope !== identity.releaseScope ||
consumption.releaseSet?.releaseSetDigest !== consumption.releaseSetDigest
) {
fail(`${label} catalog consumption is invalid`);
}
return consumption;
}
function sameCatalog(authority, consumption, identity, keys) {
return (
exactKeys(authority, keys) &&
authority.mode === 'verified_release_catalog' &&
authority.sourceRevision === identity.sourceRevision &&
authority.sourceRef === identity.sourceRef &&
authority.scope === identity.releaseScope &&
authority.releaseSetDigest === consumption.releaseSetDigest &&
authority.catalogManifestDigest === consumption.catalogManifestDigest &&
authority.catalogConsumptionDigest === consumption.contentDigest
);
}
function validateLocalReport(entry, profile, consumption, identity) {
const report = entry?.value;
if (
!DIGEST_PATTERN.test(entry?.digest || '') ||
!exactKeys(report, LOCAL_REPORT_KEYS) ||
report.schemaVersion !== 1 ||
report.profile !== profile ||
!Number.isSafeInteger(report.generation) ||
report.generation < 1 ||
report.exactRepoDigest !== true ||
report.composeMerge !== true ||
report.rolloutActive !== true ||
report.durableReceipt !== true ||
report.sqliteWriteContract !== 37 ||
report.sqliteBackup !== true ||
report.sqliteRestorePrepared !== true ||
report.sqliteRestoreCommitted !== true ||
report.sqliteRestoreRolloutRecovered !== true ||
report.sqliteRestoreReplayUnchanged !== true ||
report.sqliteEvidenceCollected !== true ||
report.sqliteCollectedRolloutReplayUnchanged !== true ||
report.gracefulCleanup !== true ||
report.compatible !== true ||
!sameCatalog(
report.releaseAuthority,
consumption,
identity,
LOCAL_AUTHORITY_KEYS,
) ||
!DIGEST_PATTERN.test(report.releaseAuthority.selectionDigest || '')
) {
fail(`${profile} deployment evidence is invalid`);
}
return report;
}
function validateClusterReport(entry, consumption, identity) {
const report = entry?.value;
if (
!DIGEST_PATTERN.test(entry?.digest || '') ||
!exactKeys(report, CLUSTER_REPORT_KEYS) ||
report.schemaVersion !== 1 ||
report.schema !== 'qinglong/kubernetes-deployment-live-evidence@v1' ||
report.kubernetes === null ||
typeof report.kubernetes !== 'object' ||
typeof report.kubernetes.serverVersion !== 'string' ||
!/^linux\/(?:amd64|arm64)$/u.test(report.kubernetes.architecture || '') ||
report.kubernetes.nodeCount !== 3 ||
typeof report.kubernetes.clusterUid !== 'string' ||
report.deployment === null ||
typeof report.deployment !== 'object' ||
report.deployment.replicas !== 0 ||
report.deployment.immutableImages !== true ||
report.deployment.headPhase !== 'committed' ||
report.deployment.headGeneration !== 2 ||
!DIGEST_PATTERN.test(report.deployment.deploymentDigest || '') ||
!sameCatalog(
report.releaseAuthority,
consumption,
identity,
CLUSTER_AUTHORITY_KEYS,
) ||
report.releaseAuthority.version !== identity.version ||
report.releaseAuthority.immutableReference !==
consumption.immutableReference ||
!DIGEST_PATTERN.test(report.preflightDigest || '') ||
!DIGEST_PATTERN.test(report.receiptDigest || '') ||
report.receiptAuditCompatible !== true ||
report.retirement === null ||
typeof report.retirement !== 'object' ||
report.retirement.receiptAuditCompatible !== true ||
report.retirement.targetAbsent !== true ||
report.retirement.uidResourceVersionDeletePreconditions !== true ||
report.retirement.deploymentHeadCas !== true ||
report.retirement.unixSocketProxy !== true ||
report.serverSideDryRun !== true ||
report.serverSideApply !== true ||
report.convergenceRead !== true ||
report.deploymentHeadCas !== true ||
report.resourceInventoryClosed !== true ||
report.crossResourceAtomicity !== false ||
report.cleanupComplete !== true
) {
fail('cluster deployment evidence is invalid');
}
return report;
}
function validateEvidenceCatalog(consumption, finalizer) {
if (
consumption.releaseSetDigest !== finalizer.releaseSetDigest ||
consumption.catalogManifestDigest !== finalizer.catalogManifestDigest ||
consumption.immutableReference !== finalizer.immutableReference
) {
fail('deployment evidence is detached from the finalizer catalog');
}
}
function createDeploymentReadinessReceipt(input) {
const identity = input?.identity;
const release = validateIdentity(identity);
const finalizer = validateConsumption(
input.finalizerConsumption,
identity,
'finalizer',
);
const evidence = [];
if (identity.releaseScope !== 'cluster') {
const localConsumption = validateConsumption(
input.local?.consumption,
identity,
'local deployment',
);
validateEvidenceCatalog(localConsumption, finalizer);
const edge = validateLocalReport(
input.local?.edge,
'edge',
localConsumption,
identity,
);
const standalone = validateLocalReport(
input.local?.standalone,
'standalone',
localConsumption,
identity,
);
if (
edge.releaseAuthority.selectionDigest !==
standalone.releaseAuthority.selectionDigest
) {
fail('local profiles used different release selections');
}
evidence.push({
family: 'local',
catalogConsumptionDigest: localConsumption.contentDigest,
selectionDigest: edge.releaseAuthority.selectionDigest,
reports: [
{
profile: 'edge',
digest: input.local.edge.digest,
generation: edge.generation,
},
{
profile: 'standalone',
digest: input.local.standalone.digest,
generation: standalone.generation,
},
],
verification: {
exactRepoDigest: true,
rolloutActive: true,
sqliteBackupRestore: true,
gracefulCleanup: true,
},
});
} else if (input.local !== undefined) {
fail('local deployment evidence is forbidden for cluster scope');
}
if (identity.releaseScope !== 'local') {
const clusterConsumption = validateConsumption(
input.cluster?.consumption,
identity,
'cluster deployment',
);
validateEvidenceCatalog(clusterConsumption, finalizer);
const cluster = validateClusterReport(
input.cluster?.report,
clusterConsumption,
identity,
);
evidence.push({
family: 'cluster',
catalogConsumptionDigest: clusterConsumption.contentDigest,
reportDigest: input.cluster.report.digest,
kubernetes: {
serverVersion: cluster.kubernetes.serverVersion,
architecture: cluster.kubernetes.architecture,
nodeCount: cluster.kubernetes.nodeCount,
},
verification: {
immutableImages: true,
serverSideDryRun: true,
serverSideApply: true,
receiptAudited: true,
retirementAudited: true,
cleanupComplete: true,
},
});
} else if (input.cluster !== undefined) {
fail('cluster deployment evidence is forbidden for local scope');
}
const unsigned = {
schemaVersion: 1,
schema: SCHEMA,
release,
sourceRepository: identity.sourceRepository,
catalog: {
schema: CATALOG_CONSUMPTION_SCHEMA,
immutableReference: finalizer.immutableReference,
manifestDigest: finalizer.catalogManifestDigest,
releaseSetDigest: finalizer.releaseSetDigest,
finalizerConsumptionDigest: finalizer.contentDigest,
},
requiredDeploymentFamilies: requiredFamilies(identity.releaseScope),
evidence,
verification: { ...VERIFICATION },
};
return Object.freeze({
...unsigned,
receiptDigest: sha256(JSON.stringify(unsigned)),
});
}
function validateDeploymentReadinessReceipt(receipt, binding = {}) {
if (
!exactKeys(receipt, [
'schemaVersion',
'schema',
'release',
'sourceRepository',
'catalog',
'requiredDeploymentFamilies',
'evidence',
'verification',
'receiptDigest',
]) ||
receipt.schemaVersion !== 1 ||
receipt.schema !== SCHEMA ||
!exactKeys(receipt.release, [
'version',
'sourceRevision',
'sourceRef',
'scope',
]) ||
receipt.release.sourceRef !== `refs/tags/v${receipt.release.version}` ||
!REVISION_PATTERN.test(receipt.release.sourceRevision || '') ||
!['local', 'cluster', 'all'].includes(receipt.release.scope) ||
!REPOSITORY_PATTERN.test(receipt.sourceRepository || '') ||
!exactKeys(receipt.catalog, [
'schema',
'immutableReference',
'manifestDigest',
'releaseSetDigest',
'finalizerConsumptionDigest',
]) ||
receipt.catalog.schema !== CATALOG_CONSUMPTION_SCHEMA ||
!DIGEST_PATTERN.test(receipt.catalog.manifestDigest || '') ||
!DIGEST_PATTERN.test(receipt.catalog.releaseSetDigest || '') ||
!DIGEST_PATTERN.test(receipt.catalog.finalizerConsumptionDigest || '') ||
!receipt.catalog.immutableReference.endsWith(
`@${receipt.catalog.manifestDigest}`,
) ||
JSON.stringify(receipt.requiredDeploymentFamilies) !==
JSON.stringify(requiredFamilies(receipt.release.scope)) ||
!Array.isArray(receipt.evidence) ||
JSON.stringify(receipt.evidence.map((entry) => entry.family)) !==
JSON.stringify(receipt.requiredDeploymentFamilies) ||
JSON.stringify(receipt.verification) !== JSON.stringify(VERIFICATION) ||
!DIGEST_PATTERN.test(receipt.receiptDigest || '')
) {
fail('deployment readiness receipt shape is invalid');
}
const { receiptDigest, ...unsigned } = receipt;
if (receiptDigest !== sha256(JSON.stringify(unsigned))) {
fail('deployment readiness receipt digest is invalid');
}
if (
(binding.release !== undefined &&
JSON.stringify(receipt.release) !== JSON.stringify(binding.release)) ||
(binding.sourceRepository !== undefined &&
receipt.sourceRepository !== binding.sourceRepository) ||
(binding.releaseSetDigest !== undefined &&
receipt.catalog.releaseSetDigest !== binding.releaseSetDigest) ||
(binding.catalogManifestDigest !== undefined &&
receipt.catalog.manifestDigest !== binding.catalogManifestDigest) ||
(binding.immutableReference !== undefined &&
receipt.catalog.immutableReference !== binding.immutableReference)
) {
fail('deployment readiness receipt authority is detached');
}
for (const entry of receipt.evidence) {
if (
!DIGEST_PATTERN.test(entry.catalogConsumptionDigest || '') ||
(entry.family === 'local' &&
(!exactKeys(entry, [
'family',
'catalogConsumptionDigest',
'selectionDigest',
'reports',
'verification',
]) ||
!DIGEST_PATTERN.test(entry.selectionDigest || '') ||
!Array.isArray(entry.reports) ||
entry.reports.length !== 2 ||
JSON.stringify(entry.reports.map((report) => report.profile)) !==
JSON.stringify(['edge', 'standalone']) ||
entry.reports.some(
(report) =>
!exactKeys(report, ['profile', 'digest', 'generation']) ||
!DIGEST_PATTERN.test(report.digest || '') ||
!Number.isSafeInteger(report.generation) ||
report.generation < 1,
) ||
JSON.stringify(entry.verification) !==
JSON.stringify({
exactRepoDigest: true,
rolloutActive: true,
sqliteBackupRestore: true,
gracefulCleanup: true,
}))) ||
(entry.family === 'cluster' &&
(!exactKeys(entry, [
'family',
'catalogConsumptionDigest',
'reportDigest',
'kubernetes',
'verification',
]) ||
!DIGEST_PATTERN.test(entry.reportDigest || '') ||
!exactKeys(entry.kubernetes, [
'serverVersion',
'architecture',
'nodeCount',
]) ||
typeof entry.kubernetes.serverVersion !== 'string' ||
entry.kubernetes?.nodeCount !== 3 ||
!/^linux\/(?:amd64|arm64)$/u.test(
entry.kubernetes?.architecture || '',
) ||
JSON.stringify(entry.verification) !==
JSON.stringify({
immutableImages: true,
serverSideDryRun: true,
serverSideApply: true,
receiptAudited: true,
retirementAudited: true,
cleanupComplete: true,
}))) ||
!['local', 'cluster'].includes(entry.family)
) {
fail('deployment readiness evidence summary is invalid');
}
}
return receipt;
}
function auditDeploymentReadinessReceipt(actual, input) {
const expected = createDeploymentReadinessReceipt(input);
validateDeploymentReadinessReceipt(actual);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
fail('deployment readiness receipt differs from the exact live evidence');
}
return Object.freeze({
compatible: true,
releaseScope: actual.release.scope,
releaseSetDigest: actual.catalog.releaseSetDigest,
catalogManifestDigest: actual.catalog.manifestDigest,
requiredDeploymentFamilies: [...actual.requiredDeploymentFamilies],
reportCount: actual.evidence.reduce(
(count, entry) => count + (entry.family === 'local' ? 2 : 1),
0,
),
tagMutation: false,
});
}
function canonicalAbsolute(value, label) {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.resolve(value) !== value
) {
fail(`${label} path must be canonical and absolute`);
}
return value;
}
function readCanonicalReport(filePath, label) {
const resolved = canonicalAbsolute(filePath, label);
const stat = fs.lstatSync(resolved);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size < 2 ||
stat.size > MAX_REPORT_BYTES ||
fs.realpathSync(resolved) !== resolved ||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved) ||
(typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
(stat.mode & 0o077) !== 0
) {
fail(`${label} must be one owner-private bounded regular file`);
}
const contents = fs.readFileSync(resolved);
let value;
try {
value = JSON.parse(contents.toString('utf8'));
} catch {
fail(`${label} must contain valid JSON`);
}
if (!contents.equals(Buffer.from(canonicalJson(value), 'utf8'))) {
fail(`${label} must use canonical JSON encoding`);
}
return Object.freeze({ value, digest: sha256(contents) });
}
function writeNoReplace(filePath, value) {
const resolved = canonicalAbsolute(filePath, 'output');
const parent = fs.lstatSync(path.dirname(resolved));
if (
!parent.isDirectory() ||
parent.isSymbolicLink() ||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
) {
fail('output parent must be one canonical directory');
}
const descriptor = fs.openSync(resolved, 'wx', 0o600);
try {
fs.writeFileSync(descriptor, canonicalJson(value));
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
function parseArguments(argv) {
const values = {};
for (const argument of argv) {
const match = /^--([a-z-]+)=(.+)$/u.exec(argument);
if (!match || Object.hasOwn(values, match[1]))
fail('arguments are invalid');
values[match[1]] = match[2];
}
if (!['create', 'audit'].includes(values.mode)) fail('arguments are invalid');
const common = [
'finalizer-consumption-bundle',
'mode',
'release-scope',
'repository-owner',
'source-ref',
'source-repository',
'source-revision',
'version',
];
const local =
values['release-scope'] === 'cluster'
? []
: ['edge-report', 'local-consumption-bundle', 'standalone-report'];
const cluster =
values['release-scope'] === 'local'
? []
: ['cluster-consumption-bundle', 'cluster-report'];
const terminal = values.mode === 'create' ? ['output'] : ['receipt'];
const expected = [...common, ...local, ...cluster, ...terminal].sort();
if (JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected)) {
fail('arguments are invalid');
}
return Object.freeze({
mode: values.mode,
version: values.version,
sourceRevision: values['source-revision'],
sourceRef: values['source-ref'],
releaseScope: values['release-scope'],
repositoryOwner: values['repository-owner'],
sourceRepository: values['source-repository'],
finalizerConsumptionBundle: values['finalizer-consumption-bundle'],
localConsumptionBundle: values['local-consumption-bundle'],
edgeReport: values['edge-report'],
standaloneReport: values['standalone-report'],
clusterConsumptionBundle: values['cluster-consumption-bundle'],
clusterReport: values['cluster-report'],
output: values.output,
receipt: values.receipt,
});
}
function evidenceInput(options) {
const identity = {
version: options.version,
sourceRevision: options.sourceRevision,
sourceRef: options.sourceRef,
releaseScope: options.releaseScope,
repositoryOwner: options.repositoryOwner,
sourceRepository: options.sourceRepository,
};
const auditOptions = {
...identity,
outputDirectory: options.finalizerConsumptionBundle,
};
const input = {
identity,
finalizerConsumption: auditCeremonyBundle(auditOptions),
};
if (options.releaseScope !== 'cluster') {
input.local = {
consumption: auditCeremonyBundle({
...identity,
outputDirectory: options.localConsumptionBundle,
}),
edge: readCanonicalReport(options.edgeReport, 'edge report'),
standalone: readCanonicalReport(
options.standaloneReport,
'standalone report',
),
};
}
if (options.releaseScope !== 'local') {
input.cluster = {
consumption: auditCeremonyBundle({
...identity,
outputDirectory: options.clusterConsumptionBundle,
}),
report: readCanonicalReport(options.clusterReport, 'cluster report'),
};
}
return input;
}
function runCli(argv, output = process.stdout) {
const options = parseArguments(argv);
const input = evidenceInput(options);
if (options.mode === 'create') {
const receipt = createDeploymentReadinessReceipt(input);
writeNoReplace(options.output, receipt);
output.write(canonicalJson(receipt));
return receipt;
}
const actual = readCanonicalReport(
options.receipt,
'deployment readiness receipt',
).value;
const audit = auditDeploymentReadinessReceipt(actual, input);
output.write(canonicalJson(audit));
return audit;
}
if (require.main === module) {
try {
runCli(process.argv.slice(2));
} catch (error) {
process.stderr.write(
`${
error instanceof Error
? error.message
: 'release deployment readiness failed'
}\n`,
);
process.exitCode = 1;
}
}
module.exports = Object.freeze({
SCHEMA,
QingLong3ReleaseDeploymentReadinessError,
auditDeploymentReadinessReceipt,
createDeploymentReadinessReceipt,
parseArguments,
runCli,
validateDeploymentReadinessReceipt,
});
@@ -13,12 +13,16 @@ const {
auditCatalogPlan, auditCatalogPlan,
auditCatalogReceipt, auditCatalogReceipt,
} = require('./ql3-release-catalog-contract.cjs'); } = require('./ql3-release-catalog-contract.cjs');
const {
SCHEMA: DEPLOYMENT_READINESS_SCHEMA,
validateDeploymentReadinessReceipt,
} = require('./ql3-release-deployment-readiness-contract.cjs');
const PUBLICATION_PLAN_SCHEMA = 'qinglong/release-publication-plan@v1'; const PUBLICATION_PLAN_SCHEMA = 'qinglong/release-publication-plan@v2';
const TAG_OBSERVATION_SCHEMA = const TAG_OBSERVATION_SCHEMA =
'qinglong/release-publication-tag-observation@v1'; 'qinglong/release-publication-tag-observation@v1';
const CLOSURE_RECEIPT_SCHEMA = const CLOSURE_RECEIPT_SCHEMA =
'qinglong/release-publication-closure-receipt@v1'; 'qinglong/release-publication-closure-receipt@v2';
const MAX_JSON_BYTES = 1024 * 1024; const MAX_JSON_BYTES = 1024 * 1024;
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/u; const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/u;
@@ -28,10 +32,11 @@ const REQUIRED_PREREQUISITES = Object.freeze({
releaseSetProvenance: 'attested_before_catalog_publication', releaseSetProvenance: 'attested_before_catalog_publication',
catalogSignature: 'verified_exact_workflow_identity', catalogSignature: 'verified_exact_workflow_identity',
catalogProvenance: 'verified_source_tag_and_revision', catalogProvenance: 'verified_source_tag_and_revision',
catalogReceipt: 'attested_before_tag_promotion', catalogReceipt: 'attested_before_deployment_gates',
deploymentReadiness: 'scope_exact_receipt_attested_before_tag_promotion',
}); });
const PROMOTION_POLICY = Object.freeze({ const PROMOTION_POLICY = Object.freeze({
authority: 'verified_immutable_catalog', authority: 'verified_catalog_bound_deployments',
inventory: 'bounded_exact_repository_tags', inventory: 'bounded_exact_repository_tags',
conflict: 'fail_closed_before_any_tag_mutation', conflict: 'fail_closed_before_any_tag_mutation',
recovery: 'reuse_exact_digest_only', recovery: 'reuse_exact_digest_only',
@@ -40,8 +45,9 @@ const PROMOTION_POLICY = Object.freeze({
registryTagCas: false, registryTagCas: false,
}); });
const CLOSURE_VERIFICATION = Object.freeze({ const CLOSURE_VERIFICATION = Object.freeze({
authority: 'verified_immutable_catalog', authority: 'verified_catalog_bound_deployments',
catalogReadyBeforeTagMutation: true, catalogReadyBeforeTagMutation: true,
deploymentReadyBeforeTagMutation: true,
allTagsExactDigest: true, allTagsExactDigest: true,
inventory: 'bounded_exact_repository_tags', inventory: 'bounded_exact_repository_tags',
conflict: 'fail_closed_before_any_tag_mutation', conflict: 'fail_closed_before_any_tag_mutation',
@@ -159,6 +165,7 @@ function createPublicationPlan(
releaseSet, releaseSet,
catalogPlan, catalogPlan,
catalogReceipt, catalogReceipt,
deploymentReadiness,
catalogManifestContents, catalogManifestContents,
catalogManifestDigest, catalogManifestDigest,
options, options,
@@ -172,6 +179,13 @@ function createPublicationPlan(
catalogManifestContents, catalogManifestContents,
catalogManifestDigest, catalogManifestDigest,
); );
validateDeploymentReadinessReceipt(deploymentReadiness, {
release: releaseSet.release,
sourceRepository: options.sourceRepository,
releaseSetDigest: releaseSet.releaseSetDigest,
catalogManifestDigest,
immutableReference: catalogReceipt.catalog.immutableReference,
});
const images = releaseSet.images.map((image) => { const images = releaseSet.images.map((image) => {
const registryRepository = image.reference.slice( const registryRepository = image.reference.slice(
0, 0,
@@ -203,6 +217,15 @@ function createPublicationPlan(
manifestDigest: catalogManifestDigest, manifestDigest: catalogManifestDigest,
immutableReference: catalogReceipt.catalog.immutableReference, immutableReference: catalogReceipt.catalog.immutableReference,
}, },
deploymentReadiness: {
schema: DEPLOYMENT_READINESS_SCHEMA,
receiptDigest: deploymentReadiness.receiptDigest,
finalizerConsumptionDigest:
deploymentReadiness.catalog.finalizerConsumptionDigest,
requiredDeploymentFamilies: [
...deploymentReadiness.requiredDeploymentFamilies,
],
},
requiredPrerequisites: { ...REQUIRED_PREREQUISITES }, requiredPrerequisites: { ...REQUIRED_PREREQUISITES },
promotionPolicy: { ...PROMOTION_POLICY }, promotionPolicy: { ...PROMOTION_POLICY },
images, images,
@@ -221,6 +244,7 @@ function validatePublicationPlan(plan) {
'release', 'release',
'releaseSet', 'releaseSet',
'catalog', 'catalog',
'deploymentReadiness',
'requiredPrerequisites', 'requiredPrerequisites',
'promotionPolicy', 'promotionPolicy',
'images', 'images',
@@ -262,6 +286,25 @@ function validatePublicationPlan(plan) {
!plan.catalog.immutableReference.endsWith( !plan.catalog.immutableReference.endsWith(
`@${plan.catalog.manifestDigest}`, `@${plan.catalog.manifestDigest}`,
) || ) ||
!exactKeys(plan.deploymentReadiness, [
'schema',
'receiptDigest',
'finalizerConsumptionDigest',
'requiredDeploymentFamilies',
]) ||
plan.deploymentReadiness.schema !== DEPLOYMENT_READINESS_SCHEMA ||
!DIGEST_PATTERN.test(plan.deploymentReadiness.receiptDigest || '') ||
!DIGEST_PATTERN.test(
plan.deploymentReadiness.finalizerConsumptionDigest || '',
) ||
JSON.stringify(plan.deploymentReadiness.requiredDeploymentFamilies) !==
JSON.stringify(
plan.release.scope === 'local'
? ['local']
: plan.release.scope === 'cluster'
? ['cluster']
: ['local', 'cluster'],
) ||
JSON.stringify(plan.requiredPrerequisites) !== JSON.stringify(plan.requiredPrerequisites) !==
JSON.stringify(REQUIRED_PREREQUISITES) || JSON.stringify(REQUIRED_PREREQUISITES) ||
JSON.stringify(plan.promotionPolicy) !== JSON.stringify(PROMOTION_POLICY) || JSON.stringify(plan.promotionPolicy) !== JSON.stringify(PROMOTION_POLICY) ||
@@ -394,6 +437,12 @@ function createClosureReceipt(plan, observation) {
planDigest: plan.planDigest, planDigest: plan.planDigest,
releaseSet: { ...plan.releaseSet }, releaseSet: { ...plan.releaseSet },
catalog: { ...plan.catalog }, catalog: { ...plan.catalog },
deploymentReadiness: {
...plan.deploymentReadiness,
requiredDeploymentFamilies: [
...plan.deploymentReadiness.requiredDeploymentFamilies,
],
},
tagObservationDigest: observation.observationDigest, tagObservationDigest: observation.observationDigest,
publishedTags: observation.tags.map((entry) => ({ ...entry })), publishedTags: observation.tags.map((entry) => ({ ...entry })),
verification: { ...CLOSURE_VERIFICATION }, verification: { ...CLOSURE_VERIFICATION },
@@ -414,6 +463,7 @@ function auditClosureReceipt(actual, plan, observation) {
releaseSetDigest: actual.releaseSet.releaseSetDigest, releaseSetDigest: actual.releaseSet.releaseSetDigest,
releaseScope: actual.release.scope, releaseScope: actual.release.scope,
catalogManifestDigest: actual.catalog.manifestDigest, catalogManifestDigest: actual.catalog.manifestDigest,
deploymentReadinessReceiptDigest: actual.deploymentReadiness.receiptDigest,
publishedTagCount: actual.publishedTags.length, publishedTagCount: actual.publishedTags.length,
allTagsExactDigest: actual.verification.allTagsExactDigest, allTagsExactDigest: actual.verification.allTagsExactDigest,
registryTagCas: actual.verification.registryTagCas, registryTagCas: actual.verification.registryTagCas,
@@ -444,6 +494,7 @@ function parseArguments(argv) {
'catalog-manifest-digest', 'catalog-manifest-digest',
'catalog-plan', 'catalog-plan',
'catalog-receipt', 'catalog-receipt',
'deployment-readiness',
'mode', 'mode',
'output', 'output',
'release-set', 'release-set',
@@ -470,6 +521,7 @@ function parseArguments(argv) {
releaseSet: values['release-set'], releaseSet: values['release-set'],
catalogPlan: values['catalog-plan'], catalogPlan: values['catalog-plan'],
catalogReceipt: values['catalog-receipt'], catalogReceipt: values['catalog-receipt'],
deploymentReadiness: values['deployment-readiness'],
catalogManifest: values['catalog-manifest'], catalogManifest: values['catalog-manifest'],
catalogManifestDigest: values['catalog-manifest-digest'], catalogManifestDigest: values['catalog-manifest-digest'],
version: values.version, version: values.version,
@@ -490,6 +542,10 @@ function runCli(argv, output = process.stdout) {
options.catalogReceipt, options.catalogReceipt,
'catalog receipt', 'catalog receipt',
); );
const deploymentReadiness = readCanonicalJson(
options.deploymentReadiness,
'deployment readiness receipt',
);
const manifest = readBoundedFile( const manifest = readBoundedFile(
options.catalogManifest, options.catalogManifest,
'catalog manifest', 'catalog manifest',
@@ -498,6 +554,7 @@ function runCli(argv, output = process.stdout) {
releaseSet, releaseSet,
catalogPlan, catalogPlan,
catalogReceipt, catalogReceipt,
deploymentReadiness,
manifest, manifest,
options.catalogManifestDigest, options.catalogManifestDigest,
options, options,
@@ -73,7 +73,7 @@ test('rejects verifier, embedded artifact and release workflow drift', () => {
'.github/workflows/ql3-image-release.yml', '.github/workflows/ql3-image-release.yml',
(source) => (source) =>
source.replace( source.replace(
'Promote final tags only after the catalog receipt is attested', 'Promote final tags only after every required deployment gate',
'Promote mutable release tags', 'Promote mutable release tags',
), ),
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT', 'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
+90 -38
View File
@@ -95,6 +95,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
tagAfterVerification: true, tagAfterVerification: true,
tagAfterCompleteReleaseSet: true, tagAfterCompleteReleaseSet: true,
tagAfterVerifiedCatalog: true, tagAfterVerifiedCatalog: true,
tagAfterRequiredDeploymentGates: true,
boundedRepositoryTagInventory: true, boundedRepositoryTagInventory: true,
allTagConflictsCheckedBeforeMutation: true, allTagConflictsCheckedBeforeMutation: true,
responseLossRecovery: 'reuse_exact_digest_only', responseLossRecovery: 'reuse_exact_digest_only',
@@ -107,7 +108,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
privateEvidenceFreshnessRevalidatedAtClosure: true, privateEvidenceFreshnessRevalidatedAtClosure: true,
exactScopeClosure: true, exactScopeClosure: true,
standaloneInspection: true, standaloneInspection: true,
tagPromotionAuthority: 'verified_immutable_catalog', tagPromotionAuthority: 'verified_catalog_bound_deployments',
fileProvenanceAttested: true, fileProvenanceAttested: true,
artifactRetentionDays: 90, artifactRetentionDays: 90,
crossRepositoryAtomicity: false, crossRepositoryAtomicity: false,
@@ -135,10 +136,11 @@ test('accepts the reviewed native CI and digest release contracts', () => {
receiptAttested: true, receiptAttested: true,
}, },
finalPublicationClosure: { finalPublicationClosure: {
planSchema: 'qinglong/release-publication-plan@v1', planSchema: 'qinglong/release-publication-plan@v2',
tagObservationSchema: 'qinglong/release-publication-tag-observation@v1', tagObservationSchema: 'qinglong/release-publication-tag-observation@v1',
receiptSchema: 'qinglong/release-publication-closure-receipt@v1', receiptSchema: 'qinglong/release-publication-closure-receipt@v2',
catalogReadyBeforeTagMutation: true, catalogReadyBeforeTagMutation: true,
deploymentReadyBeforeTagMutation: true,
allTagsExactDigest: true, allTagsExactDigest: true,
tagsPerImage: 2, tagsPerImage: 2,
conflictPolicy: 'fail_closed_before_any_tag_mutation', conflictPolicy: 'fail_closed_before_any_tag_mutation',
@@ -147,6 +149,16 @@ test('accepts the reviewed native CI and digest release contracts', () => {
registryTagCas: false, registryTagCas: false,
receiptAttested: true, receiptAttested: true,
}, },
deploymentReadiness: {
receiptSchema: 'qinglong/release-deployment-readiness-receipt@v1',
scopes: ['local', 'cluster', 'all'],
localProfiles: ['edge', 'standalone'],
clusterNodes: 3,
independentFinalizerCatalogConsumption: true,
exactEvidenceBytesAudited: true,
jobResultOnlyAuthority: false,
receiptAttestedBeforeTagMutation: true,
},
catalogDeploymentGate: { catalogDeploymentGate: {
scopes: ['cluster', 'all'], scopes: ['cluster', 'all'],
catalogAuthority: 'immutable_digest_after_public_consumption', catalogAuthority: 'immutable_digest_after_public_consumption',
@@ -155,6 +167,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
installReceiptAudited: true, installReceiptAudited: true,
fencedRetirementReceiptAudited: true, fencedRetirementReceiptAudited: true,
publicationAuthority: false, publicationAuthority: false,
requiredForFinalization: true,
}, },
localCatalogDeploymentGate: { localCatalogDeploymentGate: {
scopes: ['local', 'all'], scopes: ['local', 'all'],
@@ -164,6 +177,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
rolloutReceiptAudited: true, rolloutReceiptAudited: true,
gracefulCleanup: true, gracefulCleanup: true,
publicationAuthority: false, publicationAuthority: false,
requiredForFinalization: true,
}, },
localRolloutPreflight: true, localRolloutPreflight: true,
localRolloutApply: true, localRolloutApply: true,
@@ -179,6 +193,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
'catalog-consumption', 'catalog-consumption',
'catalog-bound-local-compose-deployment', 'catalog-bound-local-compose-deployment',
'catalog-bound-k3s-deployment', 'catalog-bound-k3s-deployment',
'deployment-readiness',
'release-tags', 'release-tags',
'release-publication-closure', 'release-publication-closure',
], ],
@@ -219,6 +234,17 @@ test('rejects removal of the final publication closure contract tests', () => {
); );
}); });
test('rejects removal of the deployment readiness contract tests', () => {
const mutated = ciSource.replace(
'test/back/ql3ReleaseDeploymentReadinessContract.test.cjs',
'test/back/deployment-readiness-tests-removed.test.cjs',
);
assert.throws(
() => auditClusterImageCiWorkflow(mutated),
/durable catalog, deployment-lock and workflow negative tests/,
);
});
test('rejects removal of the private release evidence receipt contract tests', () => { test('rejects removal of the private release evidence receipt contract tests', () => {
const mutated = ciSource.replace( const mutated = ciSource.replace(
'test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs', 'test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs',
@@ -641,7 +667,7 @@ test('rejects a movable action tag in the privileged release job', () => {
)}actions/checkout@v6${releaseSource.slice(offset + pinned.length)}`; )}actions/checkout@v6${releaseSource.slice(offset + pinned.length)}`;
assert.throws( assert.throws(
() => auditReleaseWorkflow(mutated), () => auditReleaseWorkflow(mutated),
/privileged publisher|immutable checkout action|release-set job|cluster release must read/, /privileged publisher|immutable checkout action|release-set job|cluster release must read|release finalization/,
); );
}); });
@@ -995,7 +1021,7 @@ test('rejects a deployment lock without standalone inspection', () => {
)}`; )}`;
assert.throws( assert.throws(
() => auditReleaseWorkflow(mutated), () => auditReleaseWorkflow(mutated),
/independently inspect, durably publish/, /independently inspect and durably publish/,
); );
}); });
@@ -1006,7 +1032,7 @@ test('rejects a catalog title that leaks the runner temporary path', () => {
); );
assert.throws( assert.throws(
() => auditReleaseWorkflow(mutated), () => auditReleaseWorkflow(mutated),
/independently inspect, durably publish/, /independently inspect and durably publish/,
); );
}); });
@@ -1017,7 +1043,7 @@ test('rejects a catalog publication without byte-exact round trip', () => {
); );
assert.throws( assert.throws(
() => auditReleaseWorkflow(mutated), () => auditReleaseWorkflow(mutated),
/independently inspect, durably publish/, /independently inspect and durably publish/,
); );
}); });
@@ -1028,7 +1054,7 @@ test('rejects direct overwrite publication to the catalog discovery tag', () =>
); );
assert.throws( assert.throws(
() => auditReleaseWorkflow(mutated), () => auditReleaseWorkflow(mutated),
/independently inspect, durably publish/, /independently inspect and durably publish/,
); );
}); });
@@ -1039,7 +1065,7 @@ test('rejects catalog publication without a conflict-aware decision', () => {
); );
assert.throws( assert.throws(
() => auditReleaseWorkflow(mutated), () => auditReleaseWorkflow(mutated),
/independently inspect, durably publish/, /independently inspect and durably publish/,
); );
}); });
@@ -1050,7 +1076,7 @@ test('rejects catalog publication without bounded tag inventory classification',
); );
assert.throws( assert.throws(
() => auditReleaseWorkflow(mutated), () => auditReleaseWorkflow(mutated),
/independently inspect, durably publish/, /independently inspect and durably publish/,
); );
}); });
@@ -1061,7 +1087,7 @@ test('rejects response-loss recovery that cannot reuse an exact catalog digest',
); );
assert.throws( assert.throws(
() => auditReleaseWorkflow(mutated), () => auditReleaseWorkflow(mutated),
/independently inspect, durably publish/, /independently inspect and durably publish/,
); );
}); });
@@ -1072,7 +1098,7 @@ test('rejects using the mutable catalog discovery tag as deployment authority',
); );
assert.throws( assert.throws(
() => auditReleaseWorkflow(mutated), () => auditReleaseWorkflow(mutated),
/independently inspect, durably publish/, /independently inspect and durably publish/,
); );
}); });
@@ -1083,7 +1109,7 @@ test('rejects durable catalog provenance detached from its manifest digest', ()
); );
assert.throws( assert.throws(
() => auditReleaseWorkflow(mutated), () => auditReleaseWorkflow(mutated),
/independently inspect, durably publish/, /independently inspect and durably publish/,
); );
}); });
@@ -1094,30 +1120,62 @@ test('rejects a release-catalog receipt without file provenance', () => {
); );
assert.throws( assert.throws(
() => auditReleaseWorkflow(mutated), () => auditReleaseWorkflow(mutated),
/independently inspect, durably publish/, /independently inspect and durably publish/,
); );
}); });
test('rejects final tag publication before catalog receipt attestation', () => { test('rejects final tag publication before deployment readiness', () => {
const mutated = releaseSource.replace( const mutated = releaseSource.replace(
'Promote final tags only after the catalog receipt is attested', 'Promote final tags only after every required deployment gate',
'Promote tags after release-set audit only', 'Promote tags before deployment readiness',
); );
assert.throws( assert.throws(
() => auditReleaseWorkflow(mutated), () => auditReleaseWorkflow(mutated),
/release tags and the final closure receipt|release-set job must download/, /release finalization|release tags and the final closure receipt/,
); );
}); });
test('rejects finalization that ignores a required deployment job result', () => {
const mutated = releaseSource.replace(
"needs.release-catalog-local-deployment-live.result == 'success'",
"needs.release-catalog-local-deployment-live.result != 'failure'",
);
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
});
test('rejects deployment readiness without exact downloaded evidence bundles', () => {
const mutated = releaseSource.replace(
' path: ${{ runner.temp }}/ql3-release-catalog-deployment',
' path: ${{ runner.temp }}/ql3-release-catalog-deployment/report.json',
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/cluster release must read the newly published immutable catalog/,
);
});
test('rejects a publication plan detached from deployment readiness', () => {
const mutated = releaseSource.replace(
' --deployment-readiness="${readiness}" \\',
' --deployment-readiness="${catalog_receipt}" \\',
);
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
});
test('rejects deployment readiness without its own pre-promotion attestation', () => {
const mutated = releaseSource.replace(
' subject-path: ${{ steps.final-publication.outputs.readiness }}',
' subject-path: ${{ steps.final-publication.outputs.plan }}',
);
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
});
test('rejects final tag promotion without bounded repository inventory', () => { test('rejects final tag promotion without bounded repository inventory', () => {
const mutated = releaseSource.replace( const mutated = releaseSource.replace(
" 'tag',\n 'ls',\n image.registryRepository,", " 'tag',\n 'ls',\n image.registryRepository,",
" 'image',\n 'digest',\n image.registryRepository,", " 'image',\n 'digest',\n image.registryRepository,",
); );
assert.throws( assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
() => auditReleaseWorkflow(mutated),
/release-set job must download only same-run records/,
);
}); });
test('rejects omission of the final publication closure audit', () => { test('rejects omission of the final publication closure audit', () => {
@@ -1125,39 +1183,33 @@ test('rejects omission of the final publication closure audit', () => {
' --mode=close \\', ' --mode=close \\',
' --mode=audit \\', ' --mode=audit \\',
); );
assert.throws( assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
() => auditReleaseWorkflow(mutated),
/release-set job must download only same-run records/,
);
}); });
test('rejects omitting tag observations from the durable closure bundle', () => { test('rejects omitting tag observations from the durable closure bundle', () => {
const mutated = releaseSource.replace( const mutated = releaseSource.replace(
'observations="${BUNDLE}/qinglong3-release-publication-tag-observation-', 'observations="${FINALIZATION_ROOT}/qinglong3-release-publication-tag-observation-',
'observations="${RUNNER_TEMP}/qinglong3-release-publication-tag-observation-', 'observations="${RUNNER_TEMP}/qinglong3-release-publication-tag-observation-',
); );
assert.throws( assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
() => auditReleaseWorkflow(mutated),
/release-set job must download only same-run records/,
);
}); });
test('rejects a final closure receipt without its own attestation', () => { test('rejects a final closure receipt without its own attestation', () => {
const mutated = releaseSource.replace( const mutated = releaseSource.replace(
' subject-path: ${{ steps.final-publication.outputs.receipt }}', ' subject-path: ${{ steps.final-publication.outputs.closure }}',
' subject-path: ${{ steps.catalog-receipt.outputs.receipt }}', ' subject-path: ${{ steps.final-publication.outputs.readiness }}',
);
assert.throws(
() => auditReleaseWorkflow(mutated),
/release-set job must download only same-run records/,
); );
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
}); });
test('rejects a short-lived deployment digest lock', () => { test('rejects a short-lived deployment digest lock', () => {
const marker = ' retention-days: 90'; const marker = ' retention-days: 90';
assert.equal(releaseSource.includes(marker), true); assert.equal(releaseSource.includes(marker), true);
const mutated = releaseSource.replace(marker, ' retention-days: 1'); const mutated = releaseSource.replace(marker, ' retention-days: 1');
assert.throws(() => auditReleaseWorkflow(mutated), /deployment lock/); assert.throws(
() => auditReleaseWorkflow(mutated),
/release-set job|release finalization/,
);
}); });
test('rejects removal of the digest-bound OS vulnerability attestation', () => { test('rejects removal of the digest-bound OS vulnerability attestation', () => {
@@ -0,0 +1,327 @@
'use strict';
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const test = require('node:test');
const {
SCHEMA,
auditDeploymentReadinessReceipt,
createDeploymentReadinessReceipt,
parseArguments,
validateDeploymentReadinessReceipt,
} = require('../../scripts/ql3-release-deployment-readiness-contract.cjs');
const VERSION = '3.0.0-alpha.0';
const REVISION = 'd'.repeat(40);
const OWNER = 'qinglong-release';
const SOURCE_REPOSITORY = `${OWNER}/qinglong`;
const MANIFEST_DIGEST = `sha256:${'a'.repeat(64)}`;
const RELEASE_SET_DIGEST = `sha256:${'b'.repeat(64)}`;
function sha256(value) {
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
}
function identity(scope) {
return {
version: VERSION,
sourceRevision: REVISION,
sourceRef: `refs/tags/v${VERSION}`,
releaseScope: scope,
repositoryOwner: OWNER,
sourceRepository: SOURCE_REPOSITORY,
};
}
function consumption(scope, marker) {
return {
compatible: true,
releaseScope: scope,
sourceRepository: SOURCE_REPOSITORY,
workflowIdentity: `https://github.com/${SOURCE_REPOSITORY}/.github/workflows/ql3-image-release.yml@refs/tags/v${VERSION}`,
releaseSetDigest: RELEASE_SET_DIGEST,
catalogManifestDigest: MANIFEST_DIGEST,
immutableReference: `ghcr.io/${OWNER}/qinglong3-release-catalog@${MANIFEST_DIGEST}`,
imageCount: scope === 'all' ? 5 : scope === 'local' ? 1 : 4,
discoveryTagAuthority: 'none',
externalToolResultsReplayed: false,
deploymentMutation: false,
contentDigest: sha256(marker),
releaseSet: {
release: {
version: VERSION,
sourceRevision: REVISION,
sourceRef: `refs/tags/v${VERSION}`,
scope,
},
releaseSetDigest: RELEASE_SET_DIGEST,
},
};
}
function reportEntry(value) {
return {
value,
digest: sha256(`${JSON.stringify(value)}\n`),
};
}
function localReport(scope, catalog, profile) {
return reportEntry({
schemaVersion: 1,
profile,
generation: 2,
exactRepoDigest: true,
composeMerge: true,
rolloutActive: true,
durableReceipt: true,
sqliteWriteContract: 37,
sqliteBackup: true,
sqliteWriteObservation: { committed: true },
sqliteRestorePrepared: true,
sqliteRestoreCommitted: true,
sqliteRestoreRolloutRecovered: true,
sqliteRestoreReplayUnchanged: true,
sqliteEvidenceCollected: true,
sqliteCollectedRolloutReplayUnchanged: true,
gracefulCleanup: true,
releaseAuthority: {
mode: 'verified_release_catalog',
sourceRevision: REVISION,
sourceRef: `refs/tags/v${VERSION}`,
scope,
releaseSetDigest: RELEASE_SET_DIGEST,
catalogManifestDigest: MANIFEST_DIGEST,
catalogConsumptionDigest: catalog.contentDigest,
selectionDigest: sha256('selection'),
},
compatible: true,
});
}
function clusterReport(scope, catalog) {
return reportEntry({
schemaVersion: 1,
schema: 'qinglong/kubernetes-deployment-live-evidence@v1',
kubernetes: {
serverVersion: 'v1.34.3+k3s1',
architecture: 'linux/amd64',
nodeCount: 3,
clusterUid: 'fixture-cluster',
},
deployment: {
namespace: 'qinglong3-system',
resourceCount: 7,
deploymentCount: 4,
replicas: 0,
fieldManager: 'qinglong3-catalog-lock',
immutableImages: true,
headName: 'ql3-deployment-head',
headPhase: 'committed',
headGeneration: 2,
deploymentDigest: sha256('deployment'),
resourceInventoryCount: 6,
},
releaseAuthority: {
mode: 'verified_release_catalog',
version: VERSION,
sourceRevision: REVISION,
sourceRef: `refs/tags/v${VERSION}`,
scope,
releaseSetDigest: RELEASE_SET_DIGEST,
catalogManifestDigest: MANIFEST_DIGEST,
catalogConsumptionDigest: catalog.contentDigest,
immutableReference: catalog.immutableReference,
},
preflightDigest: sha256('preflight'),
receiptDigest: sha256('receipt'),
receiptAuditCompatible: true,
retirement: {
preflightDigest: sha256('retirement-preflight'),
receiptDigest: sha256('retirement-receipt'),
receiptAuditCompatible: true,
targetCount: 1,
targetAbsent: true,
uidResourceVersionDeletePreconditions: true,
deploymentHeadCas: true,
inventoryCount: 6,
unixSocketProxy: true,
},
serverSideDryRun: true,
serverSideApply: true,
convergenceRead: true,
deploymentHeadCas: true,
resourceInventoryClosed: true,
crossResourceAtomicity: false,
cleanupComplete: true,
});
}
function fixture(scope = 'all') {
const finalizerConsumption = consumption(scope, 'finalizer');
const input = {
identity: identity(scope),
finalizerConsumption,
};
if (scope !== 'cluster') {
const localConsumption = consumption(scope, 'local');
input.local = {
consumption: localConsumption,
edge: localReport(scope, localConsumption, 'edge'),
standalone: localReport(scope, localConsumption, 'standalone'),
};
}
if (scope !== 'local') {
const clusterConsumption = consumption(scope, 'cluster');
input.cluster = {
consumption: clusterConsumption,
report: clusterReport(scope, clusterConsumption),
};
}
return { input, receipt: createDeploymentReadinessReceipt(input) };
}
test('requires the exact deployment families for Local, Cluster and All', () => {
for (const [scope, families, reportCount] of [
['local', ['local'], 2],
['cluster', ['cluster'], 1],
['all', ['local', 'cluster'], 3],
]) {
const { input, receipt } = fixture(scope);
assert.equal(receipt.schema, SCHEMA);
assert.deepEqual(receipt.requiredDeploymentFamilies, families);
assert.equal(
auditDeploymentReadinessReceipt(receipt, input).reportCount,
reportCount,
);
assert.equal(
validateDeploymentReadinessReceipt(receipt, {
release: receipt.release,
sourceRepository: SOURCE_REPOSITORY,
releaseSetDigest: RELEASE_SET_DIGEST,
catalogManifestDigest: MANIFEST_DIGEST,
immutableReference: receipt.catalog.immutableReference,
}),
receipt,
);
}
});
test('rejects missing, extra and cross-scope deployment evidence', () => {
const local = fixture('local').input;
assert.throws(
() =>
createDeploymentReadinessReceipt({
...local,
local: undefined,
}),
/local deployment catalog consumption is invalid/,
);
assert.throws(
() =>
createDeploymentReadinessReceipt({
...local,
cluster: fixture('cluster').input.cluster,
}),
/cluster deployment evidence is forbidden/,
);
const all = fixture('all').input;
assert.throws(
() =>
createDeploymentReadinessReceipt({
...all,
cluster: undefined,
}),
/cluster deployment catalog consumption is invalid/,
);
});
test('rejects reports detached from the independently consumed catalog', () => {
const current = fixture('all').input;
const drifted = structuredClone(current);
drifted.local.consumption.catalogManifestDigest = sha256('other-manifest');
drifted.local.consumption.immutableReference = `ghcr.io/${OWNER}/qinglong3-release-catalog@${drifted.local.consumption.catalogManifestDigest}`;
drifted.local.edge.value.releaseAuthority.catalogManifestDigest =
drifted.local.consumption.catalogManifestDigest;
drifted.local.standalone.value.releaseAuthority.catalogManifestDigest =
drifted.local.consumption.catalogManifestDigest;
assert.throws(
() => createDeploymentReadinessReceipt(drifted),
/detached from the finalizer catalog/,
);
});
test('rejects synthetic, incomplete and unclean deployment reports', () => {
for (const mutate of [
(input) => {
input.local.edge.value.releaseAuthority.mode = 'synthetic_live_fixture';
},
(input) => {
input.local.standalone.value.gracefulCleanup = false;
},
(input) => {
input.cluster.report.value.receiptAuditCompatible = false;
},
(input) => {
input.cluster.report.value.cleanupComplete = false;
},
]) {
const input = structuredClone(fixture('all').input);
mutate(input);
assert.throws(
() => createDeploymentReadinessReceipt(input),
/deployment evidence is invalid/,
);
}
});
test('rejects tampered, recomputed or authority-detached receipts', () => {
const { receipt } = fixture('all');
const tampered = structuredClone(receipt);
tampered.receiptDigest = sha256('tampered');
assert.throws(
() => validateDeploymentReadinessReceipt(tampered),
/receipt digest is invalid/,
);
const recomputed = structuredClone(receipt);
recomputed.evidence[0].unexpected = true;
delete recomputed.receiptDigest;
recomputed.receiptDigest = sha256(JSON.stringify(recomputed));
assert.throws(
() => validateDeploymentReadinessReceipt(recomputed),
/evidence summary is invalid/,
);
assert.throws(
() =>
validateDeploymentReadinessReceipt(receipt, {
releaseSetDigest: sha256('another-release-set'),
}),
/authority is detached/,
);
});
test('accepts only closed scope-specific CLI argument sets', () => {
const common = [
'--mode=create',
`--version=${VERSION}`,
`--source-revision=${REVISION}`,
`--source-ref=refs/tags/v${VERSION}`,
'--release-scope=local',
`--repository-owner=${OWNER}`,
`--source-repository=${SOURCE_REPOSITORY}`,
'--finalizer-consumption-bundle=/tmp/finalizer',
'--local-consumption-bundle=/tmp/local',
'--edge-report=/tmp/edge',
'--standalone-report=/tmp/standalone',
'--output=/tmp/receipt',
];
assert.equal(parseArguments(common).releaseScope, 'local');
assert.throws(
() => parseArguments([...common, '--cluster-report=/tmp/cluster']),
/arguments are invalid/,
);
assert.throws(
() => parseArguments(common.filter((entry) => !entry.startsWith('--edge'))),
/arguments are invalid/,
);
});
@@ -35,6 +35,9 @@ const {
const { const {
readReleaseIdentity, readReleaseIdentity,
} = require('../../scripts/lib/ql3-release-identity.cjs'); } = require('../../scripts/lib/ql3-release-identity.cjs');
const {
createDeploymentReadinessReceipt,
} = require('../../scripts/ql3-release-deployment-readiness-contract.cjs');
const { const {
privateReleaseEvidenceReceipts, privateReleaseEvidenceReceipts,
} = require('./ql3ReleaseEvidenceFixture.cjs'); } = require('./ql3ReleaseEvidenceFixture.cjs');
@@ -106,6 +109,145 @@ function manifestFor(plan) {
}); });
} }
function catalogConsumption(set, manifestDigest, marker) {
return {
compatible: true,
releaseScope: set.release.scope,
sourceRepository: identity.sourceRepository,
workflowIdentity: `https://github.com/${identity.sourceRepository}/.github/workflows/ql3-image-release.yml@${identity.sourceRef}`,
releaseSetDigest: set.releaseSetDigest,
catalogManifestDigest: manifestDigest,
immutableReference: `ghcr.io/${identity.repositoryOwner}/qinglong3-release-catalog@${manifestDigest}`,
imageCount: set.images.length,
discoveryTagAuthority: 'none',
externalToolResultsReplayed: false,
deploymentMutation: false,
contentDigest: sha256(marker),
releaseSet: set,
};
}
function localReport(profile, consumption, marker) {
return {
value: {
schemaVersion: 1,
profile,
generation: 2,
exactRepoDigest: true,
composeMerge: true,
rolloutActive: true,
durableReceipt: true,
sqliteWriteContract: 37,
sqliteBackup: true,
sqliteWriteObservation: { rows: 1 },
sqliteRestorePrepared: true,
sqliteRestoreCommitted: true,
sqliteRestoreRolloutRecovered: true,
sqliteRestoreReplayUnchanged: true,
sqliteEvidenceCollected: true,
sqliteCollectedRolloutReplayUnchanged: true,
gracefulCleanup: true,
releaseAuthority: {
mode: 'verified_release_catalog',
sourceRevision: identity.sourceRevision,
sourceRef: identity.sourceRef,
scope: consumption.releaseScope,
releaseSetDigest: consumption.releaseSetDigest,
catalogManifestDigest: consumption.catalogManifestDigest,
catalogConsumptionDigest: consumption.contentDigest,
selectionDigest: sha256('local-selection'),
},
compatible: true,
},
digest: sha256(marker),
};
}
function clusterReport(consumption) {
return {
value: {
schemaVersion: 1,
schema: 'qinglong/kubernetes-deployment-live-evidence@v1',
kubernetes: {
serverVersion: 'v1.34.3+k3s1',
architecture: 'linux/amd64',
nodeCount: 3,
clusterUid: 'cluster-fixture',
},
deployment: {
replicas: 0,
immutableImages: true,
headPhase: 'committed',
headGeneration: 2,
deploymentDigest: sha256('deployment'),
},
releaseAuthority: {
mode: 'verified_release_catalog',
version,
sourceRevision: identity.sourceRevision,
sourceRef: identity.sourceRef,
scope: consumption.releaseScope,
releaseSetDigest: consumption.releaseSetDigest,
catalogManifestDigest: consumption.catalogManifestDigest,
catalogConsumptionDigest: consumption.contentDigest,
immutableReference: consumption.immutableReference,
},
preflightDigest: sha256('preflight'),
receiptDigest: sha256('deployment-receipt'),
receiptAuditCompatible: true,
retirement: {
receiptAuditCompatible: true,
targetAbsent: true,
uidResourceVersionDeletePreconditions: true,
deploymentHeadCas: true,
unixSocketProxy: true,
},
serverSideDryRun: true,
serverSideApply: true,
convergenceRead: true,
deploymentHeadCas: true,
resourceInventoryClosed: true,
crossResourceAtomicity: false,
cleanupComplete: true,
},
digest: sha256('cluster-report'),
};
}
function deploymentReadiness(set, manifestDigest) {
const scope = set.release.scope;
const finalizerConsumption = catalogConsumption(
set,
manifestDigest,
'finalizer-consumption',
);
const input = {
identity: { ...identity, releaseScope: scope },
finalizerConsumption,
};
if (scope !== 'cluster') {
const consumption = catalogConsumption(
set,
manifestDigest,
'local-consumption',
);
input.local = {
consumption,
edge: localReport('edge', consumption, 'edge-report'),
standalone: localReport('standalone', consumption, 'standalone-report'),
};
}
if (scope !== 'local') {
const consumption = catalogConsumption(
set,
manifestDigest,
'cluster-consumption',
);
input.cluster = { consumption, report: clusterReport(consumption) };
}
return createDeploymentReadinessReceipt(input);
}
function fixture(scope = 'all') { function fixture(scope = 'all') {
const set = releaseSet(scope); const set = releaseSet(scope);
const options = { ...identity, releaseScope: scope }; const options = { ...identity, releaseScope: scope };
@@ -117,10 +259,12 @@ function fixture(scope = 'all') {
manifest, manifest,
manifestDigest, manifestDigest,
); );
const readiness = deploymentReadiness(set, manifestDigest);
const publicationPlan = createPublicationPlan( const publicationPlan = createPublicationPlan(
set, set,
catalogPlan, catalogPlan,
catalogReceipt, catalogReceipt,
readiness,
manifest, manifest,
manifestDigest, manifestDigest,
options, options,
@@ -141,6 +285,7 @@ function fixture(scope = 'all') {
manifest, manifest,
manifestDigest, manifestDigest,
catalogReceipt, catalogReceipt,
readiness,
publicationPlan, publicationPlan,
observation, observation,
}; };
@@ -164,11 +309,11 @@ test('plans tag publication only from a verified immutable catalog', () => {
assert.equal(publicationPlan.schema, PUBLICATION_PLAN_SCHEMA); assert.equal(publicationPlan.schema, PUBLICATION_PLAN_SCHEMA);
assert.equal( assert.equal(
publicationPlan.promotionPolicy.authority, publicationPlan.promotionPolicy.authority,
'verified_immutable_catalog', 'verified_catalog_bound_deployments',
); );
assert.equal( assert.equal(
publicationPlan.requiredPrerequisites.catalogReceipt, publicationPlan.requiredPrerequisites.deploymentReadiness,
'attested_before_tag_promotion', 'scope_exact_receipt_attested_before_tag_promotion',
); );
assert.equal(publicationPlan.promotionPolicy.registryTagCas, false); assert.equal(publicationPlan.promotionPolicy.registryTagCas, false);
assert.equal(publicationPlan.images.length, set.images.length); assert.equal(publicationPlan.images.length, set.images.length);
@@ -188,6 +333,7 @@ test('creates a deterministic final closure receipt for every exact tag', () =>
assert.deepEqual(first, replay); assert.deepEqual(first, replay);
assert.equal(first.publishedTags.length, publicationPlan.images.length * 2); assert.equal(first.publishedTags.length, publicationPlan.images.length * 2);
assert.equal(first.verification.catalogReadyBeforeTagMutation, true); assert.equal(first.verification.catalogReadyBeforeTagMutation, true);
assert.equal(first.verification.deploymentReadyBeforeTagMutation, true);
assert.equal(first.verification.allTagsExactDigest, true); assert.equal(first.verification.allTagsExactDigest, true);
assert.equal(first.verification.registryTagCas, false); assert.equal(first.verification.registryTagCas, false);
assert.equal( assert.equal(
@@ -223,6 +369,7 @@ test('rejects a publication plan detached from release-set or catalog evidence',
current.set, current.set,
other.catalogPlan, other.catalogPlan,
other.catalogReceipt, other.catalogReceipt,
current.readiness,
other.manifest, other.manifest,
other.manifestDigest, other.manifestDigest,
current.options, current.options,
@@ -237,6 +384,7 @@ test('rejects a publication plan detached from release-set or catalog evidence',
current.set, current.set,
current.catalogPlan, current.catalogPlan,
weakenedReceipt, weakenedReceipt,
current.readiness,
current.manifest, current.manifest,
current.manifestDigest, current.manifestDigest,
current.options, current.options,
@@ -286,6 +434,7 @@ test('runs plan, close and audit as canonical no-replace CLI stages', (t) => {
releaseSet: path.join(directory, 'release-set.json'), releaseSet: path.join(directory, 'release-set.json'),
catalogPlan: path.join(directory, 'catalog-plan.json'), catalogPlan: path.join(directory, 'catalog-plan.json'),
catalogReceipt: path.join(directory, 'catalog-receipt.json'), catalogReceipt: path.join(directory, 'catalog-receipt.json'),
readiness: path.join(directory, 'deployment-readiness.json'),
manifest: path.join(directory, 'manifest.json'), manifest: path.join(directory, 'manifest.json'),
publicationPlan: path.join(directory, 'publication-plan.json'), publicationPlan: path.join(directory, 'publication-plan.json'),
observation: path.join(directory, 'observation.json'), observation: path.join(directory, 'observation.json'),
@@ -294,6 +443,7 @@ test('runs plan, close and audit as canonical no-replace CLI stages', (t) => {
writeCanonical(files.releaseSet, value.set); writeCanonical(files.releaseSet, value.set);
writeCanonical(files.catalogPlan, value.catalogPlan); writeCanonical(files.catalogPlan, value.catalogPlan);
writeCanonical(files.catalogReceipt, value.catalogReceipt); writeCanonical(files.catalogReceipt, value.catalogReceipt);
writeCanonical(files.readiness, value.readiness);
fs.writeFileSync(files.manifest, value.manifest, { mode: 0o600 }); fs.writeFileSync(files.manifest, value.manifest, { mode: 0o600 });
writeCanonical(files.observation, value.observation); writeCanonical(files.observation, value.observation);
const planArgs = [ const planArgs = [
@@ -307,6 +457,7 @@ test('runs plan, close and audit as canonical no-replace CLI stages', (t) => {
`--release-set=${files.releaseSet}`, `--release-set=${files.releaseSet}`,
`--catalog-plan=${files.catalogPlan}`, `--catalog-plan=${files.catalogPlan}`,
`--catalog-receipt=${files.catalogReceipt}`, `--catalog-receipt=${files.catalogReceipt}`,
`--deployment-readiness=${files.readiness}`,
`--catalog-manifest=${files.manifest}`, `--catalog-manifest=${files.manifest}`,
`--catalog-manifest-digest=${value.manifestDigest}`, `--catalog-manifest-digest=${value.manifestDigest}`,
`--output=${files.publicationPlan}`, `--output=${files.publicationPlan}`,