mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): make release evidence replay deterministic
This commit is contained in:
@@ -11,6 +11,24 @@
|
||||
|
||||
最新增量证据(2026-08-18):
|
||||
|
||||
- D-347/ADR-0439(已接受;首份真实线上重放待实际 release tag):修正 D-346 收据把私有 runner `validatedAt` wall-clock 写入 durable
|
||||
JSON 导致同一 source/report 在 workflow 重跑时产生第二个 release identity 的问题。私有收据升为
|
||||
`qinglong/private-release-evidence-receipt@v2`:创建时仍以当前私有时钟执行 source-aware gate 和 24 小时 freshness 双重验证,但 durable
|
||||
projection 只保留不可变报告自身的 `observedAt`、report/static-lock/self digest,并显式声明 `freshnessValidatedAtCreation=true`、
|
||||
`durableValidationClockPublished=false` 与公开 consumer 不能重放现场报告。相同 release identity、相同私有报告字节和相同 source-controlled
|
||||
static locks 在不同有效运行时间必须生成逐字节相同收据。由于嵌入协议发生不兼容变化,release-set/OCI artifact media type 同步升为
|
||||
`qinglong/release-set@v3`/`application/vnd.qinglong.release-set.v3+json`,不在 v2 下偷换语义;catalog recovery 收紧为
|
||||
`republish_deterministic_content_then_verify_digest`,只允许相同不可变输入的同内容重放。Local 仍为零私有收据,新增工作只在短生命周期
|
||||
release runner 发生,不新增 package、依赖、数据库、migration、Pod、controller、listener、timer、设备工具或稳态资源。定向发布链
|
||||
135/135;完整 backend 1,363 项为 1,361 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0。12 项 package boundary、Cluster
|
||||
dependency、Edge import、Cluster/Worker/CloudNativePG 部署、backup、Barman/cert-manager selection、image release、deployment-lock
|
||||
surface 与 release-version 审计全部 compatible;14 档 Local artifact 与既有字节基线完全一致:默认 Edge/Standalone 为
|
||||
2,589,890/2,589,968 bytes,adopted 为 2,809,185/2,809,308 bytes,application 为 3,632,769/3,632,889 bytes,application-api 为
|
||||
3,800,322/3,800,466 bytes,AI 为 3,069,143/3,069,233 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为
|
||||
7,315,930/7,316,038 bytes。阶段完整性 PostgreSQL HA 以 18.6/arm64 重跑 142/142 gates、timeline `1→2`,独立 evidence audit
|
||||
compatible,mode-0600 报告 SHA-256 为 `db72086e971fc91c25a4a482af6634acbf4d463e567c44f42af0e7d0cc24fca2`,
|
||||
container/network/volume 残留为 0。真实线上 response-loss 重放仍只能由实际受保护 `v3` release tag 取得。
|
||||
|
||||
- D-346/ADR-0438(已接受;首份真实公开收据待实际 release tag):Cluster/All 发布不再只通过 GitHub `needs` 保存“两个私有 evidence job
|
||||
成功”的瞬时调度事实。Worker management 与 CloudNativePG DR 私有 runner 在 source-aware gate 成功后,各自产生一个
|
||||
`qinglong/private-release-evidence-receipt@v1`:只投影 exact version/source tag/revision/scope、evidence kind、24 小时 freshness、
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# ADR-0439:确定性私有证据收据与 Release-set 重放
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-18
|
||||
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-335、D-336、D-346、D-347
|
||||
- 关联 ADR:ADR-0427、ADR-0428、ADR-0430、ADR-0438
|
||||
- Supersedes:ADR-0438 的 `qinglong/private-release-evidence-receipt@v1` 与 `qinglong/release-set@v2` 聚合版本
|
||||
|
||||
## 上下文
|
||||
|
||||
ADR-0438 把 Worker management 与 CloudNativePG disaster-recovery 私有报告投影为 content-free 收据,并将两份收据纳入 durable
|
||||
release-set。v1 收据同时持久化 `observedAt` 和私有 job 实际运行时的 `validatedAt`。后者来自 runner wall-clock,不属于不可变 source
|
||||
evidence。
|
||||
|
||||
因此,同一 tag、同一 source revision、同一报告与同一静态 lock 只要在不同时间重跑,就会产生不同 receipt digest、release-set digest 和 OCI
|
||||
catalog manifest digest。这与 catalog plan 已冻结的 `republish_same_content_then_verify_digest` 恢复语义矛盾:一次合法的 workflow response-loss
|
||||
恢复可能生成第二个 release identity,甚至让无 authority 的 discovery tag 指向不同内容。
|
||||
|
||||
## 决策
|
||||
|
||||
1. 私有收据升级为 `qinglong/private-release-evidence-receipt@v2`,`schemaVersion=2`。收据继续绑定报告自身的 `observedAt`、24 小时最大年龄、
|
||||
report digest、release identity、evidence kind、静态审计摘要和 self digest,但不持久化 runner validation wall-clock。
|
||||
2. 创建路径仍必须取得当前私有 runner 时钟,并由既有 source-aware gate 以及 receipt assembler 双重验证:报告不得超过 24 小时,最多只允许五分钟
|
||||
未来时钟偏差。移除 durable clock 绝不等于移除 freshness gate。
|
||||
3. 收据显式声明 `freshnessValidatedAtCreation=true` 与 `durableValidationClockPublished=false`。公开消费者可验证结构、source/report equality 和
|
||||
digest 闭包,但仍必须保持 `publicConsumerReplay=not_possible_without_private_reports`,不能把布尔声明冒充现场重放。
|
||||
4. 相同 release identity、相同私有报告字节和相同 source-controlled static locks,无论私有 job 在有效窗口内何时重跑,必须生成逐字节相同的
|
||||
receipt。报告自身使用 owner-private、mode `0600`、commit-scoped、no-replace 发布路径;不同报告 digest 不是同一次恢复输入。
|
||||
5. release-set 升级为 `qinglong/release-set@v3`,OCI artifact/file media type 同步升级为
|
||||
`application/vnd.qinglong.release-set.v3+json`。不能在 v2 release-set 或 media type 下静默替换嵌入收据的 schema 与字段。
|
||||
6. catalog recovery 语义收紧为 `republish_deterministic_content_then_verify_digest`:只允许相同 source evidence 的确定性内容重发并回读同一 digest;
|
||||
source/report/static-lock 发生变化时必须作为新输入失败关闭,不能解释为同一发布的 response-loss replay。
|
||||
7. 不新增 package、production dependency、数据库、migration、Pod、controller、listener、timer、设备工具或稳态资源。变化只影响短生命周期
|
||||
release runner 的 JSON 字段和 SHA-256;Local scope 仍为零私有收据。
|
||||
|
||||
## 失败与恢复
|
||||
|
||||
- 当前时钟超出 freshness window:创建在写文件前失败;不得因 durable JSON 不含 wall-clock 而放宽。
|
||||
- 同一 source report 重跑:v2 收据、v3 release-set、catalog plan 与 manifest 内容必须逐字节一致,可安全重发并验证同 digest。
|
||||
- 同一 source 路径内容被替换:私有 no-replace/stable descriptor 边界或 report digest 闭包失败;不能覆盖既有发布身份。
|
||||
- v1 receipt、v2 release-set 或 v2 media type:3.0 尚未正式发布,不做隐式迁移;从原始私有证据重新运行受保护 release workflow。
|
||||
- 历史 catalog consumer:只接受 v3 release-set;不会把 v1/v2 解释成当前 schema。
|
||||
|
||||
## 部署与资源影响
|
||||
|
||||
- Edge/Standalone 设备下载的仍是一个 Local image digest 和小型 selection;不安装证据工具,不增加常驻 CPU/RSS、I/O 或连接。
|
||||
- Cluster 节点仍只消费 catalog-bound image references;确定性发生在发布工作站,不引入 Kubernetes workload 或运行期控制面。
|
||||
- v2 收据比 v1 少一个时间字符串,并增加两个固定 boolean;差异不改变制品预算或包拓扑。
|
||||
|
||||
## 被拒绝的替代方案
|
||||
|
||||
### 保留 `validatedAt` 并允许重跑产生新 catalog
|
||||
|
||||
拒绝。同一不可变 release source 将拥有多个仅由 runner 时钟区分的 durable identity,response-loss 无法与新发布区分。
|
||||
|
||||
### 把 `validatedAt` 固定为 `observedAt`
|
||||
|
||||
拒绝。两个字段相同会伪称验证与观察发生在同一时刻。删除未发布的 ephemeral clock,并明确声明其未持久化,更符合证据强度。
|
||||
|
||||
### 删除 freshness 检查以换取确定性
|
||||
|
||||
拒绝。确定性只约束 durable projection;私有创建仍必须以当前时钟重放 source-aware gate。
|
||||
|
||||
### 保持 release-set v2 与 OCI v2 media type
|
||||
|
||||
拒绝。嵌入对象从 receipt v1 变为 v2 是消费者可观察的协议变化,复用旧 schema/media type 会隐藏不兼容语义。
|
||||
|
||||
## 验证
|
||||
|
||||
- receipt contract 必须证明相同报告在两个不同有效 validation clock 下生成相同对象和相同 canonical bytes,并拒绝重新注入 `validatedAt`;
|
||||
- 既有 source/scope/report/static-lock/self-digest、私有字段、mode `0600`、stable read 与 no-replace 负向门全部保留;
|
||||
- release-set、catalog、consumption、deployment-lock 与 workflow 审计必须只接受 receipt v2、release-set v3 和 OCI v3 media type;
|
||||
- 定向发布链 135/135,完整 backend 1,363 项为 1,361 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0,12 项静态审计与 14 档
|
||||
Local artifact 均 compatible 且制品字节基线不变;PostgreSQL 18.6 arm64 HA 为 142/142、timeline `1→2`,独立报告审计 compatible,且
|
||||
Docker container/network/volume 零残留;
|
||||
- 完整测试、制品与运行门结果记录于 QL-RFC-0001 D-347;首份真实线上重放仍须由受保护 `v3` release tag 产生。
|
||||
@@ -442,6 +442,7 @@
|
||||
| [ADR-0436](./ADR-0436-post-publication-catalog-bound-kubernetes-release-gate.md) | 发布后 Catalog-bound Kubernetes Release Gate | Accepted(首份真实公开 catalog evidence 待实际 release tag) |
|
||||
| [ADR-0437](./ADR-0437-post-publication-catalog-bound-local-release-gate.md) | 发布后 Catalog-bound Local Release Gate | Accepted(首份真实公开 catalog evidence 待实际 release tag) |
|
||||
| [ADR-0438](./ADR-0438-content-free-private-release-evidence-receipt-chain.md) | 内容无关的私有发布证据收据链 | Accepted(首份真实公开收据待实际 release tag) |
|
||||
| [ADR-0439](./ADR-0439-deterministic-private-evidence-receipts-and-release-set-replay.md) | 确定性私有证据收据与 Release-set 重放 | Accepted(首份真实线上重放待实际 release tag) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -15,14 +15,16 @@ ghcr.io/<owner>/qinglong3-release-catalog:v<version>-<scope>
|
||||
|
||||
## 私有发布证据收据链
|
||||
|
||||
当前长期 authority 是 `qinglong/release-set@v2`。`local` scope 的 `evidenceReceipts` 必须为空;`cluster|all` 必须按顺序恰好包含
|
||||
`worker-management` 与 `cloudnativepg-disaster-recovery` 两份 `qinglong/private-release-evidence-receipt@v1`。每份收据绑定同一
|
||||
当前长期 authority 是 `qinglong/release-set@v3`。`local` scope 的 `evidenceReceipts` 必须为空;`cluster|all` 必须按顺序恰好包含
|
||||
`worker-management` 与 `cloudnativepg-disaster-recovery` 两份 `qinglong/private-release-evidence-receipt@v2`。每份收据绑定同一
|
||||
version/source tag/revision/scope、24 小时 freshness、私有报告 digest 和自身 digest;DR 收据还绑定 CloudNativePG backup、Barman Cloud 与
|
||||
cert-manager 三项静态审计摘要。
|
||||
cert-manager 三项静态审计摘要。v2 收据不持久化私有 runner 的 wall-clock:创建时仍必须以当前时钟完成 freshness gate,但 durable JSON 只绑定
|
||||
不可变报告的 `observedAt`。因此相同 source/report/static-lock 在 workflow 重跑时逐字节相同,不会仅因重跑时间变化而生成第二个 catalog identity。
|
||||
|
||||
这些收据不包含原始生产报告、路径、credential、token、Kubernetes object 或 transcript。公开 consumer 可以重算收据和 release-set digest,
|
||||
但必须保持 `publicConsumerReplay=not_possible_without_private_reports`;它不能声称重放了私有现场结果。原始报告不上传,只有收据以 1 天 artifact
|
||||
从私有 job 交给 release-set job,随后完整嵌入 release-set v2 并由 durable catalog 长期保护。
|
||||
从私有 job 交给 release-set job,随后完整嵌入 release-set v3 并由 durable catalog 长期保护。公开收据同时声明
|
||||
`freshnessValidatedAtCreation=true` 与 `durableValidationClockPublished=false`,避免把未发布的临时时钟伪装成可离线重放的现场证据。
|
||||
|
||||
## 发布流水线内置 Local 与 Cluster 下游门
|
||||
|
||||
@@ -404,7 +406,7 @@ SHA-256,`receipt.expectedDigest` 是 receipt 内的 `receiptDigest`。审计
|
||||
1. 只接受已验证 Cosign exact workflow identity 与 GitHub source tag/revision provenance 的 catalog immutable
|
||||
reference;discovery tag 无 authority。
|
||||
2. materializer 只能接受完整 `qinglong/release-catalog-consumption-ceremony@v1` bundle,不能接受旧的松散 `--release-set`;其中
|
||||
`qinglong/release-set@v2` 的 `release.version`、`release.sourceRef`、`release.sourceRevision`、`release.scope` 必须与变更单一致。
|
||||
`qinglong/release-set@v3` 的 `release.version`、`release.sourceRef`、`release.sourceRevision`、`release.scope` 必须与变更单一致。
|
||||
3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。
|
||||
4. Local scope 必须为零私有收据;Cluster/All 必须精确包含两份同 source、同 scope、自摘要有效且 freshness 闭合的 content-free 收据。
|
||||
static lock compatible 不等于现场证据已公开重放,任何 consumer 都必须保留该限制。
|
||||
|
||||
@@ -1303,6 +1303,7 @@ function auditReleaseWorkflow(source) {
|
||||
sourceDerived: true,
|
||||
sameRunRecords: true,
|
||||
sameRunPrivateEvidenceReceipts: true,
|
||||
deterministicPrivateEvidenceReceipts: true,
|
||||
exactScopeClosure: true,
|
||||
standaloneInspection: true,
|
||||
tagPromotionAuthority: 'complete_verified_release_set',
|
||||
@@ -1312,7 +1313,7 @@ function auditReleaseWorkflow(source) {
|
||||
},
|
||||
durableCatalog: {
|
||||
repository: 'qinglong3-release-catalog',
|
||||
artifactType: 'application/vnd.qinglong.release-set.v2+json',
|
||||
artifactType: 'application/vnd.qinglong.release-set.v3+json',
|
||||
basenameOnly: true,
|
||||
crossRunnerDeterministic: true,
|
||||
byteExactRoundTrip: true,
|
||||
|
||||
@@ -21,7 +21,7 @@ const {
|
||||
auditCertManagerSelection,
|
||||
} = require('./ql3-cert-manager-selection-audit.cjs');
|
||||
|
||||
const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v1';
|
||||
const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v2';
|
||||
const EVIDENCE_KINDS = Object.freeze([
|
||||
'worker-management',
|
||||
'cloudnativepg-disaster-recovery',
|
||||
@@ -182,12 +182,12 @@ function assembleReceipt(options) {
|
||||
fail('private evidence report digest is invalid');
|
||||
}
|
||||
const observedAtMs = Date.parse(options.observedAt);
|
||||
const validatedAtMs = options.nowMs;
|
||||
const validationClockMs = options.nowMs;
|
||||
if (
|
||||
!Number.isSafeInteger(validatedAtMs) ||
|
||||
!Number.isSafeInteger(validationClockMs) ||
|
||||
!Number.isFinite(observedAtMs) ||
|
||||
observedAtMs > validatedAtMs + MAX_FUTURE_SKEW_MS ||
|
||||
validatedAtMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000
|
||||
observedAtMs > validationClockMs + MAX_FUTURE_SKEW_MS ||
|
||||
validationClockMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000
|
||||
) {
|
||||
fail('private evidence is outside the release freshness window');
|
||||
}
|
||||
@@ -196,7 +196,7 @@ function assembleReceipt(options) {
|
||||
? staticAuditReceipts(options.root)
|
||||
: Object.freeze([]);
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
schema: RECEIPT_SCHEMA,
|
||||
release: {
|
||||
version: options.version,
|
||||
@@ -208,7 +208,6 @@ function assembleReceipt(options) {
|
||||
evidence: {
|
||||
fixture: options.fixture,
|
||||
observedAt: options.observedAt,
|
||||
validatedAt: new Date(validatedAtMs).toISOString(),
|
||||
maximumAgeSeconds: MAX_EVIDENCE_AGE_SECONDS,
|
||||
reportDigest: options.reportDigest,
|
||||
sourceReportsUploaded: false,
|
||||
@@ -217,6 +216,8 @@ function assembleReceipt(options) {
|
||||
verification: {
|
||||
sourceAwareAudit: true,
|
||||
privateEvidenceReplayed: true,
|
||||
freshnessValidatedAtCreation: true,
|
||||
durableValidationClockPublished: false,
|
||||
publicConsumerReplay: 'not_possible_without_private_reports',
|
||||
privateReportContentPublished: false,
|
||||
compatible: true,
|
||||
@@ -323,7 +324,7 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) {
|
||||
'verification',
|
||||
'receiptDigest',
|
||||
]) ||
|
||||
actual.schemaVersion !== 1 ||
|
||||
actual.schemaVersion !== 2 ||
|
||||
actual.schema !== RECEIPT_SCHEMA ||
|
||||
!exactKeys(actual.release, [
|
||||
'version',
|
||||
@@ -342,7 +343,6 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) {
|
||||
!exactKeys(actual.evidence, [
|
||||
'fixture',
|
||||
'observedAt',
|
||||
'validatedAt',
|
||||
'maximumAgeSeconds',
|
||||
'reportDigest',
|
||||
'sourceReportsUploaded',
|
||||
@@ -363,6 +363,8 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) {
|
||||
!exactKeys(actual.verification, [
|
||||
'sourceAwareAudit',
|
||||
'privateEvidenceReplayed',
|
||||
'freshnessValidatedAtCreation',
|
||||
'durableValidationClockPublished',
|
||||
'publicConsumerReplay',
|
||||
'privateReportContentPublished',
|
||||
'compatible',
|
||||
@@ -371,6 +373,8 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) {
|
||||
JSON.stringify({
|
||||
sourceAwareAudit: true,
|
||||
privateEvidenceReplayed: true,
|
||||
freshnessValidatedAtCreation: true,
|
||||
durableValidationClockPublished: false,
|
||||
publicConsumerReplay: 'not_possible_without_private_reports',
|
||||
privateReportContentPublished: false,
|
||||
compatible: true,
|
||||
@@ -378,14 +382,7 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) {
|
||||
) {
|
||||
fail('receipt shape or release binding is invalid');
|
||||
}
|
||||
const observedAtMs = Date.parse(actual.evidence.observedAt);
|
||||
const validatedAtMs = Date.parse(actual.evidence.validatedAt);
|
||||
if (
|
||||
!Number.isFinite(observedAtMs) ||
|
||||
!Number.isFinite(validatedAtMs) ||
|
||||
observedAtMs > validatedAtMs + MAX_FUTURE_SKEW_MS ||
|
||||
validatedAtMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000
|
||||
) {
|
||||
if (!Number.isFinite(Date.parse(actual.evidence.observedAt))) {
|
||||
fail('receipt freshness binding is invalid');
|
||||
}
|
||||
const { receiptDigest, ...unsigned } = actual;
|
||||
|
||||
@@ -12,7 +12,7 @@ const {
|
||||
|
||||
const CATALOG_PLAN_SCHEMA = 'qinglong/release-catalog-plan@v1';
|
||||
const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v1';
|
||||
const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v2+json';
|
||||
const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v3+json';
|
||||
const FILE_MEDIA_TYPE = ARTIFACT_TYPE;
|
||||
const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json';
|
||||
const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json';
|
||||
@@ -173,7 +173,7 @@ function createCatalogPlan(releaseSet, options) {
|
||||
discoveryTagAuthority: 'none',
|
||||
immutableDigestAuthority: 'required',
|
||||
roundTrip: 'byte_exact',
|
||||
recovery: 'republish_same_content_then_verify_digest',
|
||||
recovery: 'republish_deterministic_content_then_verify_digest',
|
||||
},
|
||||
};
|
||||
return Object.freeze({
|
||||
|
||||
@@ -20,7 +20,7 @@ const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1';
|
||||
const RELEASE_SET_SCHEMA = 'qinglong/release-set@v2';
|
||||
const RELEASE_SET_SCHEMA = 'qinglong/release-set@v3';
|
||||
const MAX_JSON_BYTES = 1024 * 1024;
|
||||
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
||||
const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/u;
|
||||
|
||||
@@ -99,6 +99,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
sourceDerived: true,
|
||||
sameRunRecords: true,
|
||||
sameRunPrivateEvidenceReceipts: true,
|
||||
deterministicPrivateEvidenceReceipts: true,
|
||||
exactScopeClosure: true,
|
||||
standaloneInspection: true,
|
||||
tagPromotionAuthority: 'complete_verified_release_set',
|
||||
@@ -108,7 +109,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
},
|
||||
durableCatalog: {
|
||||
repository: 'qinglong3-release-catalog',
|
||||
artifactType: 'application/vnd.qinglong.release-set.v2+json',
|
||||
artifactType: 'application/vnd.qinglong.release-set.v3+json',
|
||||
basenameOnly: true,
|
||||
crossRunnerDeterministic: true,
|
||||
byteExactRoundTrip: true,
|
||||
|
||||
@@ -119,6 +119,70 @@ test('rejects publishing private material in an otherwise re-digested receipt',
|
||||
);
|
||||
});
|
||||
|
||||
test('omits the ephemeral validation clock and replays identical source evidence byte-for-byte', (t) => {
|
||||
const directory = temporaryDirectory(t);
|
||||
const reportPath = path.join(directory, 'private-report.json');
|
||||
fs.writeFileSync(
|
||||
reportPath,
|
||||
`${JSON.stringify({ observedAt: '2026-08-18T00:00:00.000Z' })}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const createAt = (validationTime, outputFile) =>
|
||||
createWorkerReceipt(
|
||||
{
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
version: RELEASE.version,
|
||||
sourceRevision: RELEASE.sourceRevision,
|
||||
sourceRef: RELEASE.sourceRef,
|
||||
releaseScope: RELEASE.scope,
|
||||
reportFile: reportPath,
|
||||
ceremonyReportFile: path.join(directory, 'private-ceremony.json'),
|
||||
durableAuditReportFile: path.join(directory, 'private-durable.json'),
|
||||
pkiRotationReportFile: path.join(directory, 'private-pki.json'),
|
||||
caRolloverReportFile: path.join(directory, 'private-ca.json'),
|
||||
outputFile,
|
||||
},
|
||||
{
|
||||
now: () => Date.parse(validationTime),
|
||||
auditGate(_options, dependencies) {
|
||||
assert.equal(dependencies.now(), Date.parse(validationTime));
|
||||
return {
|
||||
fixture: 'qinglong/worker-credential-management-release-gate@v1',
|
||||
evidenceReportSha256: `sha256:${'5'.repeat(64)}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
const firstPath = path.join(directory, 'first.json');
|
||||
const secondPath = path.join(directory, 'second.json');
|
||||
const first = createAt('2026-08-18T00:05:00.000Z', firstPath);
|
||||
const second = createAt('2026-08-18T00:15:00.000Z', secondPath);
|
||||
assert.deepEqual(second, first);
|
||||
assert.equal(
|
||||
fs.readFileSync(secondPath, 'utf8'),
|
||||
fs.readFileSync(firstPath, 'utf8'),
|
||||
);
|
||||
assert.equal(Object.hasOwn(first.evidence, 'validatedAt'), false);
|
||||
assert.equal(first.verification.freshnessValidatedAtCreation, true);
|
||||
assert.equal(first.verification.durableValidationClockPublished, false);
|
||||
assert.throws(
|
||||
() =>
|
||||
createAt('2026-08-19T00:00:01.000Z', path.join(directory, 'stale.json')),
|
||||
/freshness window/,
|
||||
);
|
||||
|
||||
const widened = JSON.parse(JSON.stringify(first));
|
||||
widened.evidence.validatedAt = '2026-08-18T00:05:00.000Z';
|
||||
assert.throws(
|
||||
() =>
|
||||
inspectPrivateReleaseEvidenceReceipt(
|
||||
widened,
|
||||
options('worker-management'),
|
||||
),
|
||||
/receipt shape/,
|
||||
);
|
||||
});
|
||||
|
||||
test('audit CLI accepts a canonical receipt and rejects open argument shapes', (t) => {
|
||||
const directory = temporaryDirectory(t);
|
||||
const receiptPath = path.join(directory, 'worker-management.json');
|
||||
|
||||
@@ -79,7 +79,7 @@ function releaseSet(scope) {
|
||||
const names = images.map((entry) => entry.name);
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: 'qinglong/release-set@v2',
|
||||
schema: 'qinglong/release-set@v3',
|
||||
release,
|
||||
candidate: {
|
||||
schema: 'qinglong/release-candidate-contract@v1',
|
||||
|
||||
@@ -121,6 +121,10 @@ test('plans deterministic Local, Cluster and All OCI catalog entries', () => {
|
||||
`ghcr.io/qinglong-release/qinglong3-release-catalog:v${version}-${scope}`,
|
||||
);
|
||||
assert.equal(plan.catalog.artifactType, ARTIFACT_TYPE);
|
||||
assert.equal(
|
||||
plan.publicationPolicy.recovery,
|
||||
'republish_deterministic_content_then_verify_digest',
|
||||
);
|
||||
assert.equal(
|
||||
plan.releaseSet.bytes,
|
||||
Buffer.byteLength(`${JSON.stringify(set)}\n`),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v1';
|
||||
const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v2';
|
||||
const FIXTURES = Object.freeze({
|
||||
'worker-management': 'qinglong/worker-credential-management-release-gate@v1',
|
||||
'cloudnativepg-disaster-recovery':
|
||||
@@ -28,14 +28,13 @@ function privateReleaseEvidenceReceipt(release, evidenceKind) {
|
||||
}))
|
||||
: [];
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
schema: RECEIPT_SCHEMA,
|
||||
release: { ...release },
|
||||
evidenceKind,
|
||||
evidence: {
|
||||
fixture: FIXTURES[evidenceKind],
|
||||
observedAt: '2026-08-18T00:00:00.000Z',
|
||||
validatedAt: '2026-08-18T00:05:00.000Z',
|
||||
maximumAgeSeconds: 86_400,
|
||||
reportDigest: `sha256:${
|
||||
evidenceKind === 'worker-management' ? '5' : '6'
|
||||
@@ -46,6 +45,8 @@ function privateReleaseEvidenceReceipt(release, evidenceKind) {
|
||||
verification: {
|
||||
sourceAwareAudit: true,
|
||||
privateEvidenceReplayed: true,
|
||||
freshnessValidatedAtCreation: true,
|
||||
durableValidationClockPublished: false,
|
||||
publicConsumerReplay: 'not_possible_without_private_reports',
|
||||
privateReportContentPublished: false,
|
||||
compatible: true,
|
||||
|
||||
Reference in New Issue
Block a user