Repository navigation
Add fail-closed app release attestation to continuous audit #398
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6ab0bf7
af11952
994b23c
241835d
541b811
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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({ | ||
|
|
@@ -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', | ||
|
|
@@ -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; | ||
|
Comment on lines
+72
to
+74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This exit status is the only enforcement added, but neither the tag-producing AGENTS.md reference: AGENTS.md:L44-L46 Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For syntactically shaped but impossible dates such as 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'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The documented 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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}`); | ||
| } | ||
| } | ||
| 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/)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
continuousEligibleandcontinuousReleaseEligibleare false.docs/development/CONTINUOUS.md:95-105documents 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 👍 / 👎.