diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eceefa114..3d2ffb41f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,10 @@ jobs: env: DSH_GATE_CONCURRENCY: '8' steps: + # The archive gate reads the PR base manifest from the synthetic merge commit's first parent. - uses: actions/checkout@v6 with: + fetch-depth: 2 persist-credentials: false # Pull requests consume the default-branch cache but do not put cache @@ -60,6 +62,8 @@ jobs: pnpm install --frozen-lockfile - name: Run static gates + env: + DSH_ARCHIVE_BASE_REF: ${{ github.event.pull_request.base.sha }} run: pnpm run check:ci:static - name: Pack built tree @@ -323,6 +327,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + fetch-depth: 2 - uses: actions/setup-node@v6 with: @@ -357,6 +363,7 @@ jobs: - name: Run complete unsharded primary Node CI serially env: + DSH_ARCHIVE_BASE_REF: ${{ github.event.before }} DSH_COVERAGE_MAX_WORKERS: '1' DSH_E2E_MAX_WORKERS: '1' DSH_ESLINT_CACHE: '1' diff --git a/scripts/archived-agent-notes.spec.ts b/scripts/archived-agent-notes.spec.ts index 4ecf547c2b..f262b7ea22 100644 --- a/scripts/archived-agent-notes.spec.ts +++ b/scripts/archived-agent-notes.spec.ts @@ -5,6 +5,7 @@ import { parseArchiveManifest, renderArchiveManifest, validateArchiveArtifacts, + validateArchiveManifestExtension, type ArchiveManifest, } from './archived-agent-notes.ts' @@ -54,6 +55,29 @@ describe('archived Agent Notes', () => { ) }) + it('rejects replacing manifest seals alongside changed archive content', () => { + const artifacts = fixture() + const initial = extendArchiveManifest({ version: 1, files: {} }, artifacts) + const baseline: ArchiveManifest = { version: 1, files: initial.files } + const path = 'process/2026-07-26-example.md' + const changedArtifacts = new Map(artifacts) + changedArtifacts.set(path, Buffer.from('changed')) + const replacement = extendArchiveManifest({ version: 1, files: {} }, changedArtifacts) + const current: ArchiveManifest = { version: 1, files: replacement.files } + + expect(extendArchiveManifest(current, changedArtifacts).errors).toEqual([]) + expect(validateArchiveManifestExtension(baseline, current)).toEqual([ + `${path}: sealed manifest hash changed`, + ]) + const removed: ArchiveManifest = { + version: 1, + files: Object.fromEntries(Object.entries(current.files).filter(([candidate]) => candidate !== path)), + } + expect(validateArchiveManifestExtension(baseline, removed)).toContain( + `${path}: sealed manifest entry is missing`, + ) + }) + it('round-trips the deterministic manifest schema', () => { const content = renderArchiveManifest({ 'process/z.md': `sha256:${'a'.repeat(64)}` }) expect(parseArchiveManifest(content)).toEqual({ diff --git a/scripts/archived-agent-notes.ts b/scripts/archived-agent-notes.ts index 54ba70ddf0..bdce541ab0 100644 --- a/scripts/archived-agent-notes.ts +++ b/scripts/archived-agent-notes.ts @@ -53,6 +53,20 @@ export function renderArchiveManifest(files: Readonly>): }, null, 2)}\n` } +/** Reject changes or removals of entries sealed by a prior manifest. */ +export function validateArchiveManifestExtension( + baseline: ArchiveManifest, + current: ArchiveManifest, +): string[] { + const errors: string[] = [] + for (const [path, expected] of Object.entries(baseline.files)) { + const actual = current.files[path] + if (actual === undefined) errors.push(`${path}: sealed manifest entry is missing`) + else if (actual !== expected) errors.push(`${path}: sealed manifest hash changed`) + } + return errors +} + function validDate(value: string): boolean { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) if (match === null) return false diff --git a/scripts/verify-archived-agent-notes.ts b/scripts/verify-archived-agent-notes.ts index 0e86e15927..ca27dd8852 100644 --- a/scripts/verify-archived-agent-notes.ts +++ b/scripts/verify-archived-agent-notes.ts @@ -1,5 +1,6 @@ /** Verify and append-seal the frozen Agent Note archive. */ +import { spawnSync } from 'node:child_process' import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import { AGENT_NOTE_CLASSES, agentNoteRoot } from './agent-note-tree.ts' @@ -8,6 +9,7 @@ import { parseArchiveManifest, renderArchiveManifest, validateArchiveArtifacts, + validateArchiveManifestExtension, type ArchiveManifest, } from './archived-agent-notes.ts' @@ -20,6 +22,8 @@ if (args.length > 0 && !writeMode) { const archiveRoot = resolve(agentNoteRoot, 'archived') const manifestPath = resolve(archiveRoot, 'manifest.json') +const repoRoot = resolve(agentNoteRoot, '../..') +const manifestRepoPath = '.agents/notes/archived/manifest.json' const errors: string[] = [] const allowedRootFiles = new Set(['AGENTS.md', 'manifest.json']) const kinds = new Set() @@ -54,6 +58,20 @@ for (const kind of AGENT_NOTE_CLASSES) { } errors.push(...validateArchiveArtifacts(artifacts)) +function runGit(args: string[]): string { + const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) throw new Error(result.stderr.trim() || `git exited with status ${result.status}`) + return result.stdout +} + +function readBaselineManifest(ref: string): ArchiveManifest { + runGit(['cat-file', '-e', `${ref}^{commit}`]) + const manifestEntry = runGit(['ls-tree', '--name-only', ref, '--', manifestRepoPath]).trim() + if (manifestEntry === '') return { version: 1, files: {} } + return parseArchiveManifest(runGit(['show', `${ref}:${manifestRepoPath}`])) +} + let manifest: ArchiveManifest = { version: 1, files: {} } if (existsSync(manifestPath)) { try { @@ -65,6 +83,15 @@ if (existsSync(manifestPath)) { errors.push('archived/manifest.json is required; seal new artifacts with `pnpm run verify-archived-agent-notes --write`') } +// CI supplies its trusted pre-change commit; local writes compare with committed HEAD. +const baselineRef = process.env.DSH_ARCHIVE_BASE_REF ?? 'HEAD' +try { + const baseline = readBaselineManifest(baselineRef) + errors.push(...validateArchiveManifestExtension(baseline, manifest)) +} catch (error: unknown) { + errors.push(`archived/manifest.json: cannot read baseline ${JSON.stringify(baselineRef)}: ${error instanceof Error ? error.message : String(error)}`) +} + const extended = extendArchiveManifest(manifest, artifacts) errors.push(...extended.errors) if (!writeMode) {