mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): gate release tags on deployment readiness
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 fail,18-package clean build/test 退出 0,14/14 静态审计与
|
||||||
|
14/14 artifact 档位全部 compatible;artifact 字节保持基础 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-0442:Catalog-ready 的终态 Release Tag 发布与闭合收据
|
# ADR-0442:Catalog-ready 的终态 Release Tag 发布与闭合收据
|
||||||
|
|
||||||
- 状态:Accepted
|
- 状态:Superseded by ADR-0443(bounded promotion/closure 机制继续有效,发布前置顺序被收紧)
|
||||||
- 日期:2026-08-18
|
- 日期:2026-08-18
|
||||||
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-336、D-349、D-350
|
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-336、D-349、D-350
|
||||||
- 关联 ADR:ADR-0427、ADR-0428、ADR-0439、ADR-0441
|
- 关联 ADR:ADR-0427、ADR-0428、ADR-0439、ADR-0441
|
||||||
- Supersedes:ADR-0427 中“完整 release-set 审计后即可 promotion”的最早发布顺序
|
- Supersedes:ADR-0427 中“完整 release-set 审计后即可 promotion”的最早发布顺序
|
||||||
|
- Superseded by:ADR-0443 将最终 tag mutation 继续后移到 scope-exact deployment readiness attestation 之后
|
||||||
|
|
||||||
## 上下文
|
## 上下文
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# ADR-0443:Deployment-ready 的终态 Release Finalization
|
||||||
|
|
||||||
|
- 状态:Accepted
|
||||||
|
- 日期:2026-08-18
|
||||||
|
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-336、D-344、D-345、D-350、D-351
|
||||||
|
- 关联 ADR:ADR-0436、ADR-0437、ADR-0441、ADR-0442
|
||||||
|
- Supersedes:ADR-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 digest;mutable tag 与 closure 只表示发布可见性和终态证据。
|
||||||
|
|
||||||
|
## 失败与恢复
|
||||||
|
|
||||||
|
- catalog 已发布但任一要求的部署门失败:没有最终 image tag mutation。该 catalog 是可复验候选材料,不是 deployment-ready tag authority。
|
||||||
|
- evidence artifact 缺失、额外、权限过宽、包含 synthetic report 或引用不同 catalog:finalizer 在 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 report;credential 与现场控制材料必须留在原 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
@@ -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-0443(bounded promotion/closure 机制保留) |
|
||||||
|
| [ADR-0443](./ADR-0443-deployment-ready-terminal-release-finalization.md) | Deployment-ready 的终态 Release Finalization | Accepted(首份真实 GHCR deployment-ready finalization 待实际 release tag) |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -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、签名、provenance、receipt 和 90 天便利 bundle 全部发布后,才启动独立
|
`ql3-image-release.yml` 在 durable catalog、签名、provenance 与 receipt 全部发布后,才启动独立
|
||||||
`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 evidence;consumption 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',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user