Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
!.pa/patches/*.diff
!.pa/audit_*.md
!.pa/plan_*.md
!.pa/release_attestation_v1.5.0.json
# Archiv alter Plan-, Stand-, Spec- und Review-Prompt-Dateien (PLAN-01, 25.09.).
!.pa/archiv/
.omc/
Expand Down
14 changes: 14 additions & 0 deletions docs/setup/release-attestation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Release-Bestätigung für v1.5.0

Die App-Freigabe v1.5.0 gilt nur, wenn **du selbst** deine Abnahme bestätigst
(Entscheidung E20). Kein Agent legt die Datei `.pa/release_attestation_v1.5.0.json`
an oder ändert sie, und DevHQ bestätigt nie etwas.

1. Prüfe alle 26 Zeilen der Abnahme und entscheide selbst.
2. Lege die Datei mit genau diesen Feldern an: `release` (`"v1.5.0"`), `commit`
(Ausgabe von `git rev-parse HEAD`), `rows` (`[1,2,…,26]`), `decidedAt`
(ISO 8601, z. B. `2026-10-05T10:00:00Z`), `decidedBy` (dein Kürzel) und
`continuousEnabled` (`false`). Weitere oder fehlende Felder lehnt die Prüfung ab.
3. Committe nur diese Datei; der Arbeitsbaum muss sonst sauber sein.
4. Prüfe mit `npm run dev:continuous-audit -- --attestation .pa/release_attestation_v1.5.0.json`.
Exit 0 heißt: freigabefähig. Jede spätere Code-Änderung macht die Bestätigung ungültig.
29 changes: 21 additions & 8 deletions scripts/continuous-audit.mjs
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
#!/usr/bin/env node
import { evaluateContinuousReadiness } from './lib/continuous-readiness.mjs';
import { loadReleaseAttestation } from './lib/release-attestation.mjs';
import { doctor, inspectRuntime } from './dev-setup.mjs';
import { fileURLToPath } from 'node:url';

export function parseArgs(argv = []) {
const options = { json: false, help: false };
for (const arg of argv) {
const options = { json: false, help: false, attestation: null };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--json') options.json = true;
else if (arg === '--help' || arg === '-h') options.help = true;
else if (arg === '--attestation') {
options.attestation = argv[++i];
if (!options.attestation) throw new Error('--attestation needs a path');
} else if (arg === '--help' || arg === '-h') options.help = true;
else throw new Error(`unknown option: ${arg}`);
}
return options;
}

export function usage() {
return 'usage: npm run dev:continuous-audit [-- --json|--help]';
return 'usage: npm run dev:continuous-audit [-- --json|--help|--attestation <path>]';
}

export async function buildReport(root = process.cwd()) {
export async function buildReport(root = process.cwd(), { attestation = null } = {}) {
const appRelease = attestation
? loadReleaseAttestation(attestation, root)
: { attested: false, reason: 'no --attestation given' };
const setup = doctor(root);
const runtime = await inspectRuntime();
const readiness = evaluateContinuousReadiness({
Expand All @@ -38,9 +46,14 @@ export async function buildReport(root = process.cwd()) {
continuousExecutionEnabled: false,
stablePromotionAuthorized: false,
// Written only by the user in .pa/release_attestation_v1.5.0.json (E20).
appReleaseAttested: false,
appReleaseAttested: appRelease.attested,
},
});
if (!appRelease.attested) {
const blocker = readiness.blockers.find(item => item.id === 'attestation:appRelease');
if (blocker) blocker.reason = appRelease.reason;
readiness.attestations.appRelease.reason = appRelease.reason;
}
return {
generatedAt: new Date().toISOString(),
command: 'dev:continuous-audit',
Expand All @@ -56,9 +69,9 @@ export async function main(argv = process.argv.slice(2)) {
console.log(usage());
return 0;
}
const report = await buildReport();
const report = await buildReport(process.cwd(), options);
console.log(JSON.stringify(report, null, options.json ? 2 : 0));
return report.continuousEligible && report.continuousReleaseEligible ? 0 : 1;
return report.appReleaseEligible ? 0 : 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the continuous preflight exit contract

With a valid app attestation and ready setup/runtime, this now returns 0 even though every continuous-mode evidence flag above remains false and both continuousEligible and continuousReleaseEligible are false. docs/development/CONTINUOUS.md:95-105 documents this command as the fail-closed continuous rollout preflight whose exit remains nonzero while either eligibility result is false, so any existing wrapper that checks only the process status can now treat a blocked continuous rollout as successful; expose app-release eligibility through a distinct explicit mode or command instead of changing the continuous gate's success condition.

AGENTS.md reference: AGENTS.md:L118-L123

Useful? React with 👍 / 👎.

Comment on lines +72 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce the attestation in the actual release path

This exit status is the only enforcement added, but neither the tag-producing scripts/release.cmd nor the tag-triggered .github/workflows/release.yml invokes this command or loads the attestation. A user can therefore run the existing release script and publish signed installers with the attestation missing or refused, despite the new documentation stating that v1.5.0 release approval exists only after this confirmation; make the tag/release path fail before publishing unless the attestation passes for the exact tagged commit.

AGENTS.md reference: AGENTS.md:L44-L46

Useful? React with 👍 / 👎.

}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
Expand Down
6 changes: 4 additions & 2 deletions scripts/lib/continuous-readiness.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ test('readiness report is schema version 2', () => {
});

test('continuous audit accepts explicit output flags and rejects accidental execution flags', () => {
assert.deepEqual(parseArgs(['--json']), { json: true, help: false });
assert.deepEqual(parseArgs(['--help']), { json: false, help: true });
assert.deepEqual(parseArgs(['--json']), { json: true, help: false, attestation: null });
assert.deepEqual(parseArgs(['--help']), { json: false, help: true, attestation: null });
assert.match(usage(), /dev:continuous-audit/);
assert.throws(() => parseArgs(['--evidence', 'observations.json']), /unknown option/);
assert.equal(parseArgs(['--attestation', 'a.json']).attestation, 'a.json');
assert.throws(() => parseArgs(['--attestation']), /needs a path/);
});
72 changes: 72 additions & 0 deletions scripts/lib/release-attestation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Fail-closed loader for the user's app release attestation (E20).
// Only the user writes .pa/release_attestation_v1.5.0.json; no agent does.
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';

export const ATTESTATION_RELEASE = 'v1.5.0';
const KEYS = ['commit', 'continuousEnabled', 'decidedAt', 'decidedBy', 'release', 'rows'];
const ROWS = Array.from({ length: 26 }, (_, i) => i + 1);
const ISO_8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;

const git = (root, args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
const no = reason => ({ attested: false, reason });

/** Returns `{ attested, reason? }`; never throws. */
export function loadReleaseAttestation(file, root = process.cwd()) {
try {
const rel = path.relative(root, path.resolve(root, file)).split(path.sep).join('/');
if (!rel || rel === '..' || rel.startsWith('../')) return no('attestation path must be inside the repository');
let data;
try {
data = JSON.parse(readFileSync(path.resolve(root, file), 'utf8'));
} catch (error) {
return no(`attestation file unreadable or not JSON: ${error.code ?? 'parse error'}`);
}
if (!data || typeof data !== 'object' || Array.isArray(data)) return no('attestation must be a JSON object');
const keys = Object.keys(data).sort();
if (keys.join() !== KEYS.join()) return no(`attestation keys must be exactly ${KEYS.join(', ')}`);
if (data.release !== ATTESTATION_RELEASE) return no(`release must be ${ATTESTATION_RELEASE}`);
if (JSON.stringify(data.rows) !== JSON.stringify(ROWS)) return no('rows must be exactly 1..26');
if (typeof data.decidedAt !== 'string' || !ISO_8601.test(data.decidedAt) || Number.isNaN(Date.parse(data.decidedAt))) {
return no('decidedAt must be an ISO 8601 timestamp');
Comment on lines +31 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject normalized invalid calendar dates

For syntactically shaped but impossible dates such as 2026-02-30T10:00:00Z, Date.parse normalizes the value to March 2 rather than returning NaN, so the supposedly strict attestation accepts and permanently records an invalid decision timestamp. Validate the parsed calendar components against the supplied components, or use an ISO parser that rejects out-of-range dates.

Useful? React with 👍 / 👎.

}
if (typeof data.decidedBy !== 'string' || data.decidedBy.trim() === '') return no('decidedBy must be non-empty');
if (data.continuousEnabled !== false) return no('continuousEnabled must be false');
if (typeof data.commit !== 'string' || data.commit === '') return no('commit must be a non-empty string');
let head;
try {
head = git(root, ['rev-parse', 'HEAD']).trim();
const tracked = git(root, ['ls-files', '--stage', '--', rel]).trim();
if (!/^100(?:644|755) [0-9a-f]+ 0\t/.test(tracked)) {
return no('attestation file unreadable: it must be a committed regular file');
}
if (git(root, ['status', '--porcelain']).trim() !== '') return no('work tree is not clean');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require the attestation to be tracked

The documented .pa/release_attestation_v1.5.0.json is covered by the repository's .pa/* ignore rule, so an untracked local file is invisible to git status --porcelain; when its commit equals HEAD, the later diff check is skipped and the mutable, never-committed file is accepted. This lets the release audit report eligibility without the durable user commit promised by the workflow, so verify that the path is tracked and that its blob matches HEAD (or explicitly unignore the canonical file).

AGENTS.md reference: AGENTS.md:L44-L46

Useful? React with 👍 / 👎.

const committed = JSON.parse(git(root, ['show', `HEAD:${rel}`]));
if (JSON.stringify(data) !== JSON.stringify(committed)) {
return no('attestation file does not match its committed content');
}
} catch {
return no('git state unavailable (not a repository or no commit)');
}
if (data.commit !== head) {
try {
git(root, ['merge-base', '--is-ancestor', data.commit, head]);
} catch {
return no('commit does not match HEAD: it must be an ancestor');
}
// Committing the attestation itself moves HEAD by one commit; accept
// exactly the commits that touch nothing but the attestation file.
let changed = null;
try {
changed = git(root, ['diff', '--name-only', `${data.commit}..${head}`]).split('\n').filter(Boolean);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Inspect intervening commits instead of the net tree diff

git diff --name-only <attested>..<head> only compares the endpoint trees, so a code commit followed by a revert leaves only the attestation path in the net diff and is accepted. This contradicts the stated guarantee that every later code change invalidates the attestation and can bind release approval to a different commit history; require the attested commit to be an ancestor and inspect the paths touched by every intervening commit rather than only the final tree difference.

Useful? React with 👍 / 👎.

} catch { /* unknown commit: falls through to the mismatch */ }
if (!changed || changed.length === 0 || changed.some(name => name !== rel)) {
return no('commit does not match HEAD');
}
}
return { attested: true };
} catch (error) {
return no(`attestation check failed: ${error.message}`);
}
}
144 changes: 144 additions & 0 deletions scripts/lib/release-attestation.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { loadReleaseAttestation } from './release-attestation.mjs';

const FILE = '.pa/release_attestation_v1.5.0.json';
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();

function fixture() {
const root = mkdtempSync(path.join(tmpdir(), 'attest-'));
git(root, 'init', '-q');
git(root, 'config', 'user.email', 'test@example.invalid');
git(root, 'config', 'user.name', 'test');
writeFileSync(path.join(root, 'a.txt'), 'a');
git(root, 'add', '.');
git(root, 'commit', '-q', '-m', 'init');
mkdirSync(path.join(root, '.pa'));
return root;
}
const valid = root => ({
release: 'v1.5.0',
commit: git(root, 'rev-parse', 'HEAD'),
rows: Array.from({ length: 26 }, (_, i) => i + 1),
decidedAt: '2026-10-05T10:00:00Z',
decidedBy: 'user',
continuousEnabled: false,
});
const write = (root, value) => writeFileSync(path.join(root, FILE), typeof value === 'string' ? value : JSON.stringify(value));
function refusal(mutate, pattern, { commit = false } = {}) {
const root = fixture();
try {
const data = valid(root);
const out = mutate(data, root) ?? data;
write(root, out);
if (commit) { git(root, 'add', '.'); git(root, 'commit', '-q', '-m', 'att'); }
const result = loadReleaseAttestation(FILE, root);
assert.equal(result.attested, false);
assert.match(result.reason, pattern);
} finally { rmSync(root, { recursive: true, force: true }); }
}

test('attestation: missing file is refused with a named reason', () => {
const root = fixture();
try {
const result = loadReleaseAttestation(FILE, root);
assert.equal(result.attested, false);
assert.match(result.reason, /unreadable/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('attestation: invalid JSON is refused', () => refusal(() => '{nope', /not JSON/));
test('attestation: non-object JSON is refused', () => refusal(() => '[1]', /JSON object/));
test('attestation: extra key is refused', () => refusal(d => ({ ...d, extra: 1 }), /keys must be exactly/));
test('attestation: missing key is refused', () => refusal(d => { delete d.decidedBy; }, /keys must be exactly/));
test('attestation: wrong release is refused', () => refusal(d => { d.release = 'v1.4.0'; }, /release must be/));
test('attestation: rows must be exactly 1..26', () => refusal(d => { d.rows = d.rows.slice(1); }, /rows must be/));
test('attestation: bad decidedAt is refused', () => refusal(d => { d.decidedAt = 'yesterday'; }, /decidedAt/));
test('attestation: empty decidedBy is refused', () => refusal(d => { d.decidedBy = ' '; }, /decidedBy/));
test('attestation: continuousEnabled true is refused', () => refusal(d => { d.continuousEnabled = true; }, /continuousEnabled/));
test('attestation: commit mismatch is refused', () => refusal(d => { d.commit = 'a'.repeat(40); }, /does not match HEAD/, { commit: true }));
test('attestation: an ignored untracked file is refused', () => {
const root = fixture();
try {
writeFileSync(path.join(root, '.gitignore'), '.pa/*\n');
git(root, 'add', '.gitignore');
git(root, 'commit', '-q', '-m', 'ignore attestations');
write(root, valid(root));
assert.equal(git(root, 'status', '--porcelain'), '');
const result = loadReleaseAttestation(FILE, root);
assert.equal(result.attested, false);
assert.match(result.reason, /committed regular file/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('attestation: hidden working-copy content cannot replace the committed attestation', () => {
const root = fixture();
try {
write(root, valid(root));
git(root, 'add', '.');
git(root, 'commit', '-q', '-m', 'attest');
git(root, 'update-index', '--assume-unchanged', FILE);
write(root, { ...valid(root), commit: git(root, 'rev-parse', 'HEAD^'), decidedBy: 'replacement' });
assert.equal(git(root, 'status', '--porcelain'), '');
const result = loadReleaseAttestation(FILE, root);
assert.equal(result.attested, false);
assert.match(result.reason, /committed content/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('attestation: a non-ancestor commit is refused', () => {
const root = fixture();
try {
const base = git(root, 'rev-parse', 'HEAD');
git(root, 'checkout', '-q', '-b', 'sibling');
git(root, 'commit', '-q', '--allow-empty', '-m', 'sibling');
const sibling = git(root, 'rev-parse', 'HEAD');
git(root, 'checkout', '-q', '--detach', base);
write(root, { ...valid(root), commit: sibling });
git(root, 'add', '.');
git(root, 'commit', '-q', '-m', 'attest');
const result = loadReleaseAttestation(FILE, root);
assert.equal(result.attested, false);
assert.match(result.reason, /ancestor/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('attestation: dirty work tree is refused', () => {
const root = fixture();
try {
write(root, valid(root));
git(root, 'add', '.');
git(root, 'commit', '-q', '-m', 'attest');
writeFileSync(path.join(root, 'a.txt'), 'changed');
const result = loadReleaseAttestation(FILE, root);
assert.equal(result.attested, false);
assert.match(result.reason, /not clean/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('attestation: a non-repository is refused', () => {
const root = mkdtempSync(path.join(tmpdir(), 'attest-nogit-'));
try {
mkdirSync(path.join(root, '.pa'));
write(root, { ...valid(fixture()), commit: 'a'.repeat(40) });
const result = loadReleaseAttestation(FILE, root);
assert.equal(result.attested, false);
assert.match(result.reason, /git state unavailable/);
} finally { rmSync(root, { recursive: true, force: true }); }
});

test('attestation: happy path with the attestation itself committed on top', () => {
const root = fixture();
try {
write(root, valid(root));
git(root, 'add', '.');
git(root, 'commit', '-q', '-m', 'attest');
assert.deepEqual(loadReleaseAttestation(FILE, root), { attested: true });
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('attestation: a later code commit invalidates it', () => refusal((d, root) => {
write(root, d);
git(root, 'add', '.');
git(root, 'commit', '-q', '-m', 'attest');
writeFileSync(path.join(root, 'a.txt'), 'later');
git(root, 'commit', '-q', '-am', 'code');
}, /does not match HEAD/));
Loading