fix(notes): anchor archive seals to prior Git state
This commit is contained in:
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -37,8 +37,10 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
DSH_GATE_CONCURRENCY: '8'
|
DSH_GATE_CONCURRENCY: '8'
|
||||||
steps:
|
steps:
|
||||||
|
# The archive gate reads the PR base manifest from the synthetic merge commit's first parent.
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
# Pull requests consume the default-branch cache but do not put cache
|
# Pull requests consume the default-branch cache but do not put cache
|
||||||
@@ -60,6 +62,8 @@ jobs:
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run static gates
|
- name: Run static gates
|
||||||
|
env:
|
||||||
|
DSH_ARCHIVE_BASE_REF: ${{ github.event.pull_request.base.sha }}
|
||||||
run: pnpm run check:ci:static
|
run: pnpm run check:ci:static
|
||||||
|
|
||||||
- name: Pack built tree
|
- name: Pack built tree
|
||||||
@@ -323,6 +327,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
@@ -357,6 +363,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run complete unsharded primary Node CI serially
|
- name: Run complete unsharded primary Node CI serially
|
||||||
env:
|
env:
|
||||||
|
DSH_ARCHIVE_BASE_REF: ${{ github.event.before }}
|
||||||
DSH_COVERAGE_MAX_WORKERS: '1'
|
DSH_COVERAGE_MAX_WORKERS: '1'
|
||||||
DSH_E2E_MAX_WORKERS: '1'
|
DSH_E2E_MAX_WORKERS: '1'
|
||||||
DSH_ESLINT_CACHE: '1'
|
DSH_ESLINT_CACHE: '1'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
parseArchiveManifest,
|
parseArchiveManifest,
|
||||||
renderArchiveManifest,
|
renderArchiveManifest,
|
||||||
validateArchiveArtifacts,
|
validateArchiveArtifacts,
|
||||||
|
validateArchiveManifestExtension,
|
||||||
type ArchiveManifest,
|
type ArchiveManifest,
|
||||||
} from './archived-agent-notes.ts'
|
} 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', () => {
|
it('round-trips the deterministic manifest schema', () => {
|
||||||
const content = renderArchiveManifest({ 'process/z.md': `sha256:${'a'.repeat(64)}` })
|
const content = renderArchiveManifest({ 'process/z.md': `sha256:${'a'.repeat(64)}` })
|
||||||
expect(parseArchiveManifest(content)).toEqual({
|
expect(parseArchiveManifest(content)).toEqual({
|
||||||
|
|||||||
@@ -53,6 +53,20 @@ export function renderArchiveManifest(files: Readonly<Record<string, string>>):
|
|||||||
}, null, 2)}\n`
|
}, 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 {
|
function validDate(value: string): boolean {
|
||||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
|
||||||
if (match === null) return false
|
if (match === null) return false
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Verify and append-seal the frozen Agent Note archive. */
|
/** Verify and append-seal the frozen Agent Note archive. */
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import { AGENT_NOTE_CLASSES, agentNoteRoot } from './agent-note-tree.ts'
|
import { AGENT_NOTE_CLASSES, agentNoteRoot } from './agent-note-tree.ts'
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
parseArchiveManifest,
|
parseArchiveManifest,
|
||||||
renderArchiveManifest,
|
renderArchiveManifest,
|
||||||
validateArchiveArtifacts,
|
validateArchiveArtifacts,
|
||||||
|
validateArchiveManifestExtension,
|
||||||
type ArchiveManifest,
|
type ArchiveManifest,
|
||||||
} from './archived-agent-notes.ts'
|
} from './archived-agent-notes.ts'
|
||||||
|
|
||||||
@@ -20,6 +22,8 @@ if (args.length > 0 && !writeMode) {
|
|||||||
|
|
||||||
const archiveRoot = resolve(agentNoteRoot, 'archived')
|
const archiveRoot = resolve(agentNoteRoot, 'archived')
|
||||||
const manifestPath = resolve(archiveRoot, 'manifest.json')
|
const manifestPath = resolve(archiveRoot, 'manifest.json')
|
||||||
|
const repoRoot = resolve(agentNoteRoot, '../..')
|
||||||
|
const manifestRepoPath = '.agents/notes/archived/manifest.json'
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
const allowedRootFiles = new Set(['AGENTS.md', 'manifest.json'])
|
const allowedRootFiles = new Set(['AGENTS.md', 'manifest.json'])
|
||||||
const kinds = new Set<string>()
|
const kinds = new Set<string>()
|
||||||
@@ -54,6 +58,20 @@ for (const kind of AGENT_NOTE_CLASSES) {
|
|||||||
}
|
}
|
||||||
errors.push(...validateArchiveArtifacts(artifacts))
|
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: {} }
|
let manifest: ArchiveManifest = { version: 1, files: {} }
|
||||||
if (existsSync(manifestPath)) {
|
if (existsSync(manifestPath)) {
|
||||||
try {
|
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`')
|
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)
|
const extended = extendArchiveManifest(manifest, artifacts)
|
||||||
errors.push(...extended.errors)
|
errors.push(...extended.errors)
|
||||||
if (!writeMode) {
|
if (!writeMode) {
|
||||||
|
|||||||
Reference in New Issue
Block a user