mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 09:58:46 +08:00
feat(ql3): revalidate release evidence at closure
This commit is contained in:
@@ -11,6 +11,24 @@
|
||||
|
||||
最新增量证据(2026-08-18):
|
||||
|
||||
- D-348/ADR-0440(已接受;首份真实线上闭合待实际 release tag):补齐 D-347 确定性收据与实际 tag promotion 之间的 freshness 窗口。此前私有
|
||||
job 在收据创建时验证 24 小时 freshness,但 release-set aggregate 只检查 `observedAt` 可解析与 receipt digest;镜像构建、扫描和 attestation
|
||||
延迟后,创建时有效的收据可能在闭合时已经过期。现在 `cluster|all` aggregate 与紧随其后的 source-record audit 必须各自取得 runner-owned 当前
|
||||
时钟,对两份 receipt 公开的 `observedAt` 重新执行 24 小时最大年龄和五分钟未来偏差门;缺失/非安全整数/过期/未来漂移均在 release-set 写文件、
|
||||
tag promotion 和 catalog publication 前失败关闭。当前时钟没有 CLI 参数、环境变量或 durable 字段,只参与瞬时准入;因此同一 source evidence 在
|
||||
两个有效闭合时钟下仍生成逐字节相同的 receipt v2、release-set v3 与 catalog identity。Local scope 保持零私有收据且该门为
|
||||
`not_applicable`;历史 standalone inspection 继续验证结构/identity/self-digest 而不以今天的时钟淘汰合法 catalog,并诚实声明未重放 source
|
||||
records/private reports。本 Gate 不修改 durable schema/media type,不新增 package、生产依赖、数据库、migration、Pod、controller、RBAC、
|
||||
listener、timer、设备工具或稳态资源。定向 release receipt/set/catalog/consumption/deployment-lock/image audit 回归 137/137;完整 backend
|
||||
1,365 项为 1,363 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 全部 compatible 且保持既有字节基线:默认 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 18.6 arm64 physical HA 再次通过 142/142、
|
||||
timeline `1→2`,报告 SHA-256 为 `7566a54f86f3e4e0fee2096a49ea2877d827595575c7075bc44b65314cb2ba19`,离线审计通过且无
|
||||
`ql3-ha-*` Docker 容器、卷或网络残留;真实线上 closure 仍只能由实际受保护 `v3` release tag 取得。
|
||||
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# ADR-0440:Release-set 闭合时私有证据 Freshness 重验证
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-18
|
||||
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-335、D-346、D-347、D-348
|
||||
- 关联 ADR:ADR-0427、ADR-0438、ADR-0439
|
||||
- Extends:ADR-0439 的确定性私有证据收据与 release-set replay
|
||||
|
||||
## 上下文
|
||||
|
||||
ADR-0439 从私有证据收据中移除了 runner `validatedAt` wall-clock,使同一 release identity、私有报告和静态 lock 在合法 workflow 重跑时生成相同
|
||||
receipt、release-set 与 catalog digest。创建私有收据时仍会用当前私有 runner 时钟执行 24 小时 freshness gate。
|
||||
|
||||
但 release-set job 可能在镜像构建、扫描、签名和 attestation 后才消费短期收据。D347 的聚合路径只验证 `observedAt` 可解析、receipt identity 和
|
||||
self digest,没有在 tag promotion 前以闭合时钟重新计算年龄。GitHub artifact 的一天 retention 是存储配置,不是精确的证据 freshness authority;
|
||||
收据在创建时有效,不代表在 release-set 闭合时仍有效。
|
||||
|
||||
## 决策
|
||||
|
||||
1. `cluster|all` release-set closure 必须取得一个 runner-owned、safe-integer validation clock。该时钟不是 release-set input field,也不能由 CLI
|
||||
参数、环境变量、image record 或私有 receipt 指定。
|
||||
2. `inspectPrivateReleaseEvidenceReceipt` 在 closure caller 显式提供 validation clock 时,除 exact shape/source/report/self-digest 外,还必须用公开的
|
||||
`observedAt` 重新执行 24 小时最大年龄和五分钟未来偏差门。
|
||||
3. release-set `aggregate` 与紧随其后的 source-record `audit` 各自从进程内当前时钟执行该门;缺失、非安全整数、过期或未来漂移均在写 release-set、
|
||||
tag promotion 和 catalog publication 前失败关闭。
|
||||
4. validation clock 只参与准入,不进入 receipt、release-set、catalog plan、OCI manifest 或 digest。两个不同但都在有效窗口内的闭合时钟必须生成
|
||||
逐字节相同的 release-set。
|
||||
5. `local` scope 必须保持零私有收据,closure freshness 为 `not_applicable`,不要求该时钟即可创建同一 Local release-set。
|
||||
6. `inspectReleaseSet` 是历史 durable catalog 的 standalone verifier,不以当前日期淘汰曾经合法的发布。它继续验证结构、identity、image/receipt 顺序与
|
||||
self digest,并诚实声明 source records/private reports 未重放;当前年龄不能证明过去发布时的 freshness。
|
||||
7. 不升级 receipt v2、release-set v3 或 OCI v3 media type,因为 durable bytes 和消费者结构没有变化;本 Gate 只收紧创建/审计时可接受的瞬时输入。
|
||||
|
||||
## 失败与恢复
|
||||
|
||||
- 收据在创建时有效、闭合时超过 24 小时:aggregate 在任何 durable 输出和 tag mutation 前失败,必须重新取得 source-aware 私有报告及其确定性收据。
|
||||
- 收据时间超过闭合时钟五分钟:视为时钟/报告异常并失败,不能通过推迟发布等待其“自然有效”。
|
||||
- aggregate response-loss 后以同一 source evidence 重跑:新的闭合时钟若仍在窗口内,生成相同 bytes;若已过期则必须重新取得私有证据,而不是复用旧收据。
|
||||
- 历史 catalog 多年后验真:standalone inspection 仍可通过 structural/digest/provenance 链;不得声称重新执行了当年的私有 freshness gate。
|
||||
- Local 发布:没有私有 evidence receipt,不执行 Cluster freshness 语义,也不新增设备或发布 runner 工作。
|
||||
|
||||
## 部署与资源影响
|
||||
|
||||
- Edge/Standalone 设备、Local image、selection 和 Compose 路径零变化,不安装发布工具,不增加 CPU/RSS、I/O、timer、listener 或 updater。
|
||||
- Cluster 节点和 Kubernetes object 零变化;重验证只发生在短生命周期 release-set runner,成本为两次时间读取和两个时间戳的常数比较。
|
||||
- 不新增 workspace package、生产依赖、数据库、migration、SQL、Pod、controller、RBAC、Secret、网络连接或持久状态。
|
||||
|
||||
## 被拒绝的替代方案
|
||||
|
||||
### 重新把 `validatedAt` 写入 receipt
|
||||
|
||||
拒绝。它会恢复 D347 已消除的 wall-clock digest 漂移,让合法 workflow retry 产生第二个 catalog identity。
|
||||
|
||||
### 依赖一天的 GitHub artifact retention
|
||||
|
||||
拒绝。retention 是删除上限,不保证下载瞬间的精确年龄、未来偏差或 tag promotion 前重验证,也不是 release contract 的可测试语义。
|
||||
|
||||
### 让 CLI 传入 `--validation-clock`
|
||||
|
||||
拒绝。发布者可选择旧时钟会把 freshness gate 变成自报事实。测试通过显式 dependency injection 驱动边界,生产 CLI 不开放该输入面。
|
||||
|
||||
### 在所有历史 catalog inspection 中按当前时间拒绝
|
||||
|
||||
拒绝。这样所有合法发布都会在 24 小时后不可审计,并把“当前仍新鲜”错误等同于“发布时曾通过 freshness gate”。
|
||||
|
||||
## 验证
|
||||
|
||||
- 同一 Cluster source evidence 在两个不同有效 closure clock 下生成对象和 canonical bytes 完全相同;
|
||||
- 超过 24 小时、未来偏差和缺失 validation clock 均在 release-set 写入前失败;
|
||||
- CLI production path 使用内部 clock,测试证明 stale retry 不产生输出;
|
||||
- Local scope、历史 standalone inspection、catalog consumption 和 deployment lock 保持兼容;
|
||||
- 定向发布链 137/137,完整 backend 1,365 项为 1,363 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0;
|
||||
- 12 项静态审计和 14 档 Local artifact 全部 compatible,Edge/Standalone 至 MCP 的既有制品字节与 RSS 上限未漂移;
|
||||
- PostgreSQL 18.6 arm64 physical HA 通过 142/142、timeline `1→2`,证据 SHA-256 为
|
||||
`7566a54f86f3e4e0fee2096a49ea2877d827595575c7075bc44b65314cb2ba19`,离线审计 compatible 且 Docker 资源零残留;
|
||||
- 完整证据记录于 QL-RFC-0001 D-348;首份真实线上 closure 仍须由受保护 `v3` release tag 产生。
|
||||
@@ -443,6 +443,7 @@
|
||||
| [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) |
|
||||
| [ADR-0440](./ADR-0440-release-set-closure-private-evidence-freshness.md) | Release-set 闭合时私有证据 Freshness 重验证 | Accepted(首份真实线上闭合待实际 release tag) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -26,6 +26,14 @@ cert-manager 三项静态审计摘要。v2 收据不持久化私有 runner 的 w
|
||||
从私有 job 交给 release-set job,随后完整嵌入 release-set v3 并由 durable catalog 长期保护。公开收据同时声明
|
||||
`freshnessValidatedAtCreation=true` 与 `durableValidationClockPublished=false`,避免把未发布的临时时钟伪装成可离线重放的现场证据。
|
||||
|
||||
创建时通过不等于可以无限期等待再闭合发布。`cluster|all` 的 release-set aggregate 与紧随其后的 independent audit 会各自从 runner 内部取得当前
|
||||
时钟,并对两份收据公开的 `observedAt` 重新执行同一 24 小时最大年龄和五分钟未来偏差门;当前时钟没有 CLI 参数、环境变量或 durable JSON 字段。
|
||||
任一收据在闭合时已过期,release-set 文件会在写入、tag promotion 与 catalog publication 前失败关闭。`local` scope 没有私有收据,因此该门为
|
||||
`not_applicable`,不会把 Cluster 私有证据成本带到 Local 发布。
|
||||
|
||||
长期 catalog 的 standalone inspection 故意不以“今天的时钟”拒绝历史发布;它验证 receipt/release/source/self-digest 闭包,但诚实返回私有现场
|
||||
证据未重放。发布时 freshness 由受保护 workflow 的 aggregate/audit 与 provenance 约束,历史 consumer 不得把当前年龄检查冒充当时的现场验证。
|
||||
|
||||
## 发布流水线内置 Local 与 Cluster 下游门
|
||||
|
||||
`local|all` scope 在 durable catalog 发布后启动独立 `release-catalog-local-deployment-live`。它与 publisher 权限隔离,从公开 catalog
|
||||
|
||||
@@ -1304,6 +1304,7 @@ function auditReleaseWorkflow(source) {
|
||||
sameRunRecords: true,
|
||||
sameRunPrivateEvidenceReceipts: true,
|
||||
deterministicPrivateEvidenceReceipts: true,
|
||||
privateEvidenceFreshnessRevalidatedAtClosure: true,
|
||||
exactScopeClosure: true,
|
||||
standaloneInspection: true,
|
||||
tagPromotionAuthority: 'complete_verified_release_set',
|
||||
|
||||
@@ -382,9 +382,20 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) {
|
||||
) {
|
||||
fail('receipt shape or release binding is invalid');
|
||||
}
|
||||
if (!Number.isFinite(Date.parse(actual.evidence.observedAt))) {
|
||||
const observedAtMs = Date.parse(actual.evidence.observedAt);
|
||||
if (!Number.isFinite(observedAtMs)) {
|
||||
fail('receipt freshness binding is invalid');
|
||||
}
|
||||
if (options.validationClockMs !== undefined) {
|
||||
const validationClockMs = options.validationClockMs;
|
||||
if (
|
||||
!Number.isSafeInteger(validationClockMs) ||
|
||||
observedAtMs > validationClockMs + MAX_FUTURE_SKEW_MS ||
|
||||
validationClockMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000
|
||||
) {
|
||||
fail('receipt is outside the release freshness window');
|
||||
}
|
||||
}
|
||||
const { receiptDigest, ...unsigned } = actual;
|
||||
if (
|
||||
!DIGEST_PATTERN.test(receiptDigest || '') ||
|
||||
|
||||
@@ -245,11 +245,14 @@ function expectedEvidenceKinds(releaseScope) {
|
||||
return releaseScope === 'local' ? [] : [...EVIDENCE_KINDS];
|
||||
}
|
||||
|
||||
function validateEvidenceReceipts(receipts, candidate) {
|
||||
function validateEvidenceReceipts(receipts, candidate, validationClockMs) {
|
||||
if (!Array.isArray(receipts)) {
|
||||
fail('private release evidence receipts must be an array');
|
||||
}
|
||||
const expectedKinds = expectedEvidenceKinds(candidate.release.scope);
|
||||
if (expectedKinds.length > 0 && !Number.isSafeInteger(validationClockMs)) {
|
||||
fail('private evidence closure validation clock is invalid');
|
||||
}
|
||||
if (receipts.length !== expectedKinds.length) {
|
||||
fail('private release evidence receipt count differs from release scope');
|
||||
}
|
||||
@@ -268,6 +271,7 @@ function validateEvidenceReceipts(receipts, candidate) {
|
||||
sourceRef: candidate.release.sourceRef,
|
||||
releaseScope: candidate.release.scope,
|
||||
evidenceKind,
|
||||
validationClockMs,
|
||||
});
|
||||
receiptsByKind.set(evidenceKind, receipt);
|
||||
}
|
||||
@@ -282,6 +286,7 @@ function createReleaseSet(options) {
|
||||
const evidenceReceipts = validateEvidenceReceipts(
|
||||
options.evidenceReceipts,
|
||||
candidate,
|
||||
options.validationClockMs,
|
||||
);
|
||||
if (!Array.isArray(options.records)) fail('image records must be an array');
|
||||
if (options.records.length !== candidate.images.length) {
|
||||
@@ -363,6 +368,8 @@ function auditReleaseSet(actual, options) {
|
||||
evidenceReceiptDigests: Object.freeze(
|
||||
actual.evidenceReceipts.map((entry) => entry.receiptDigest),
|
||||
),
|
||||
privateEvidenceFreshnessRevalidatedAtClosure:
|
||||
actual.evidenceReceipts.length === 0 ? 'not_applicable' : true,
|
||||
tagPromotionAuthority: actual.promotion.authority,
|
||||
});
|
||||
}
|
||||
@@ -646,7 +653,12 @@ function parseArguments(argv) {
|
||||
});
|
||||
}
|
||||
|
||||
function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
|
||||
function runCli(
|
||||
argv,
|
||||
root = DEFAULT_ROOT,
|
||||
output = process.stdout,
|
||||
dependencies = { now: Date.now },
|
||||
) {
|
||||
const options = parseArguments(argv);
|
||||
if (options.mode === 'inspect') {
|
||||
const report = readCanonicalJson(options.report, 'release set');
|
||||
@@ -666,12 +678,20 @@ function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
|
||||
options.evidenceReceipts,
|
||||
candidate,
|
||||
);
|
||||
let validationClockMs;
|
||||
if (candidate.release.scope !== 'local') {
|
||||
if (typeof dependencies?.now !== 'function') {
|
||||
fail('release closure clock dependency is invalid');
|
||||
}
|
||||
validationClockMs = dependencies.now();
|
||||
}
|
||||
if (options.mode === 'aggregate') {
|
||||
const releaseSet = createReleaseSet({
|
||||
...options,
|
||||
candidate,
|
||||
records,
|
||||
evidenceReceipts,
|
||||
validationClockMs,
|
||||
root,
|
||||
});
|
||||
writeNoReplace(options.output, releaseSet);
|
||||
@@ -684,6 +704,7 @@ function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
|
||||
candidate,
|
||||
records,
|
||||
evidenceReceipts,
|
||||
validationClockMs,
|
||||
root,
|
||||
});
|
||||
output.write(canonicalJson(audit));
|
||||
|
||||
@@ -100,6 +100,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
sameRunRecords: true,
|
||||
sameRunPrivateEvidenceReceipts: true,
|
||||
deterministicPrivateEvidenceReceipts: true,
|
||||
privateEvidenceFreshnessRevalidatedAtClosure: true,
|
||||
exactScopeClosure: true,
|
||||
standaloneInspection: true,
|
||||
tagPromotionAuthority: 'complete_verified_release_set',
|
||||
|
||||
@@ -102,6 +102,7 @@ function releaseSet(scope) {
|
||||
records,
|
||||
evidenceReceipts: privateReleaseEvidenceReceipts(candidate.release),
|
||||
...identity,
|
||||
validationClockMs: Date.parse('2026-08-18T00:05:00.000Z'),
|
||||
releaseScope: scope,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ function releaseSet(scope) {
|
||||
records,
|
||||
evidenceReceipts: privateReleaseEvidenceReceipts(candidate.release),
|
||||
...identity,
|
||||
validationClockMs: Date.parse('2026-08-18T00:05:00.000Z'),
|
||||
releaseScope: scope,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const identity = Object.freeze({
|
||||
sourceRevision: 'b'.repeat(40),
|
||||
sourceRef: `refs/tags/v${version}`,
|
||||
repositoryOwner: 'qinglong-release',
|
||||
validationClockMs: Date.parse('2026-08-18T00:05:00.000Z'),
|
||||
});
|
||||
|
||||
function candidate(scope) {
|
||||
@@ -74,12 +75,13 @@ function writeCanonical(filePath, value) {
|
||||
test('aggregates the independent Local image into one immutable release set', () => {
|
||||
const releaseCandidate = candidate('local');
|
||||
const records = recordsFor(releaseCandidate);
|
||||
const { validationClockMs: _unused, ...localIdentity } = identity;
|
||||
const releaseSet = createReleaseSet({
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
evidenceReceipts: evidenceFor(releaseCandidate),
|
||||
...identity,
|
||||
...localIdentity,
|
||||
releaseScope: 'local',
|
||||
});
|
||||
assert.deepEqual(
|
||||
@@ -97,7 +99,7 @@ test('aggregates the independent Local image into one immutable release set', ()
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
evidenceReceipts: evidenceFor(releaseCandidate),
|
||||
...identity,
|
||||
...localIdentity,
|
||||
releaseScope: 'local',
|
||||
}).compatible,
|
||||
true,
|
||||
@@ -187,6 +189,66 @@ test('requires exact private evidence receipts only for Cluster-capable scopes',
|
||||
);
|
||||
});
|
||||
|
||||
test('revalidates private evidence freshness at closure without changing release-set bytes', () => {
|
||||
const releaseCandidate = candidate('cluster');
|
||||
const records = recordsFor(releaseCandidate);
|
||||
const evidenceReceipts = evidenceFor(releaseCandidate);
|
||||
const createAt = (validationClockMs) =>
|
||||
createReleaseSet({
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
evidenceReceipts,
|
||||
...identity,
|
||||
validationClockMs,
|
||||
releaseScope: 'cluster',
|
||||
});
|
||||
const first = createAt(Date.parse('2026-08-18T00:05:00.000Z'));
|
||||
const second = createAt(Date.parse('2026-08-18T00:15:00.000Z'));
|
||||
assert.deepEqual(second, first);
|
||||
assert.throws(
|
||||
() => createAt(Date.parse('2026-08-19T00:00:01.000Z')),
|
||||
/release freshness window/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createAt(Date.parse('2026-08-17T23:54:59.000Z')),
|
||||
/release freshness window/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createReleaseSet({
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
evidenceReceipts,
|
||||
version: identity.version,
|
||||
sourceRevision: identity.sourceRevision,
|
||||
sourceRef: identity.sourceRef,
|
||||
repositoryOwner: identity.repositoryOwner,
|
||||
releaseScope: 'cluster',
|
||||
}),
|
||||
/closure validation clock/,
|
||||
);
|
||||
assert.equal(
|
||||
auditReleaseSet(first, {
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
evidenceReceipts,
|
||||
...identity,
|
||||
releaseScope: 'cluster',
|
||||
}).privateEvidenceFreshnessRevalidatedAtClosure,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
inspectReleaseSet(first, {
|
||||
...identity,
|
||||
releaseScope: 'cluster',
|
||||
}).privateEvidenceReplayed,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects missing, duplicate and cross-candidate image evidence', () => {
|
||||
const releaseCandidate = candidate('cluster');
|
||||
const records = recordsFor(releaseCandidate);
|
||||
@@ -440,6 +502,70 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
test('CLI rejects stale private receipts at closure and keeps valid retries byte-identical', (t) => {
|
||||
const directory = temporaryDirectory(t);
|
||||
const recordsDirectory = path.join(directory, 'records');
|
||||
const evidenceDirectory = path.join(directory, 'evidence');
|
||||
fs.mkdirSync(recordsDirectory);
|
||||
fs.mkdirSync(evidenceDirectory);
|
||||
const releaseCandidate = candidate('cluster');
|
||||
const candidatePath = path.join(directory, 'candidate.json');
|
||||
writeCanonical(candidatePath, releaseCandidate);
|
||||
for (const record of recordsFor(releaseCandidate)) {
|
||||
writeCanonical(
|
||||
path.join(recordsDirectory, `${record.image.name}.json`),
|
||||
record,
|
||||
);
|
||||
}
|
||||
for (const receipt of evidenceFor(releaseCandidate)) {
|
||||
writeCanonical(
|
||||
path.join(evidenceDirectory, `${receipt.evidenceKind}.json`),
|
||||
receipt,
|
||||
);
|
||||
}
|
||||
const common = [
|
||||
'--mode=aggregate',
|
||||
`--version=${version}`,
|
||||
`--source-revision=${identity.sourceRevision}`,
|
||||
`--source-ref=${identity.sourceRef}`,
|
||||
'--release-scope=cluster',
|
||||
`--repository-owner=${identity.repositoryOwner}`,
|
||||
`--candidate=${candidatePath}`,
|
||||
`--records=${recordsDirectory}`,
|
||||
`--evidence-receipts=${evidenceDirectory}`,
|
||||
];
|
||||
const firstPath = path.join(directory, 'first.json');
|
||||
const secondPath = path.join(directory, 'second.json');
|
||||
runCli(
|
||||
[...common, `--output=${firstPath}`],
|
||||
root,
|
||||
{ write() {} },
|
||||
{ now: () => Date.parse('2026-08-18T00:05:00.000Z') },
|
||||
);
|
||||
runCli(
|
||||
[...common, `--output=${secondPath}`],
|
||||
root,
|
||||
{ write() {} },
|
||||
{ now: () => Date.parse('2026-08-18T00:15:00.000Z') },
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(secondPath, 'utf8'),
|
||||
fs.readFileSync(firstPath, 'utf8'),
|
||||
);
|
||||
const stalePath = path.join(directory, 'stale.json');
|
||||
assert.throws(
|
||||
() =>
|
||||
runCli(
|
||||
[...common, `--output=${stalePath}`],
|
||||
root,
|
||||
{ write() {} },
|
||||
{ now: () => Date.parse('2026-08-19T00:00:01.000Z') },
|
||||
),
|
||||
/release freshness window/,
|
||||
);
|
||||
assert.equal(fs.existsSync(stalePath), false);
|
||||
});
|
||||
|
||||
test('CLI rejects extra records, symlinks and open argument shapes', (t) => {
|
||||
const directory = temporaryDirectory(t);
|
||||
const recordsDirectory = path.join(directory, 'records');
|
||||
|
||||
Reference in New Issue
Block a user