From 060e933adf6bf21390f5e34967e7b5010c6f6ad1 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Mon, 13 Jul 2026 18:30:08 -0400 Subject: [PATCH] Simplify tag policy and address review feedback --- .github/workflows/ci.yml | 12 +- .github/workflows/release.yml | 19 +- scripts/backup-dar.ts | 51 +++-- scripts/check-dar-version-policy.ts | 76 +++---- scripts/dar-version-policy.test.ts | 241 +++++++------------- scripts/dar-version-policy.ts | 333 ++++++++++++---------------- 6 files changed, 318 insertions(+), 414 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c9eeee0..af655078 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,17 @@ jobs: run: npm run build - name: Check DAR version policy - run: npm run check:dar-version-policy -- --base origin/main + env: + BEFORE_SHA: ${{ github.event.before }} + run: | + base=origin/main + if [ "$GITHUB_REF" = "refs/heads/main" ]; then + base="$BEFORE_SHA" + fi + npm run check:dar-version-policy -- --base "$base" + + - name: Verify DAR backups + run: npm run verify-dars - name: Lint DAML run: npm run lint:daml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09e8e287..9cd4a58e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,11 +143,16 @@ jobs: exit 1 fi + - name: Configure Git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Build and validate package run: | npm run build npm run test:dar-version-policy - npm run check:dar-version-policy -- --all + npm run check:dar-version-policy npm run lint npm run format npm run lint:daml @@ -158,7 +163,7 @@ jobs: - name: Check DevNet deployment id: devnet_gate - run: npm run check:dar-deployment -- --package "${{ steps.release_tag.outputs.package_key }}" --network devnet + run: npm run check:dar-deployment -- --network devnet - name: Upload DAR to devnet if: steps.devnet_gate.outputs.tag_exists != 'true' @@ -171,15 +176,13 @@ jobs: PACKAGE_VERSION: ${{ steps.release_tag.outputs.version }} run: | tag="dar-deploy/devnet/${PACKAGE_NAME}/v${PACKAGE_VERSION}" - sha=$(node -e 'const l=require("./dars/dars.lock"); console.log(l.packages[`${process.env.PACKAGE_NAME}/${process.env.PACKAGE_VERSION}/${process.env.PACKAGE_NAME}.dar`].sha256)') - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + sha=$(node -e 'const fs=require("fs"); const l=JSON.parse(fs.readFileSync("dars/dars.lock", "utf8")); console.log(l.packages[`${process.env.PACKAGE_NAME}/${process.env.PACKAGE_VERSION}/${process.env.PACKAGE_NAME}.dar`].sha256)') git tag -a "$tag" -m "Deployed ${PACKAGE_NAME} v${PACKAGE_VERSION} to DevNet (${sha})" git push --no-verify origin "refs/tags/$tag" - name: Check Mainnet deployment id: mainnet_gate - run: npm run check:dar-deployment -- --package "${{ steps.release_tag.outputs.package_key }}" --network mainnet + run: npm run check:dar-deployment -- --network mainnet - name: Upload DAR to mainnet if: steps.mainnet_gate.outputs.tag_exists != 'true' @@ -192,7 +195,7 @@ jobs: PACKAGE_VERSION: ${{ steps.release_tag.outputs.version }} run: | tag="dar-deploy/mainnet/${PACKAGE_NAME}/v${PACKAGE_VERSION}" - sha=$(node -e 'const l=require("./dars/dars.lock"); console.log(l.packages[`${process.env.PACKAGE_NAME}/${process.env.PACKAGE_VERSION}/${process.env.PACKAGE_NAME}.dar`].sha256)') + sha=$(node -e 'const fs=require("fs"); const l=JSON.parse(fs.readFileSync("dars/dars.lock", "utf8")); console.log(l.packages[`${process.env.PACKAGE_NAME}/${process.env.PACKAGE_VERSION}/${process.env.PACKAGE_NAME}.dar`].sha256)') git tag -a "$tag" -m "Deployed ${PACKAGE_NAME} v${PACKAGE_VERSION} to Mainnet (${sha})" git push --no-verify origin "refs/tags/$tag" @@ -237,8 +240,6 @@ jobs: exit 1 fi - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" if ! git diff --quiet -- dars || ! git diff --cached --quiet -- dars; then echo "::error::Release mutated committed DAR backups; refusing to continue." exit 1 diff --git a/scripts/backup-dar.ts b/scripts/backup-dar.ts index 9723d610..2e0bae64 100644 --- a/scripts/backup-dar.ts +++ b/scripts/backup-dar.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import * as yaml from 'yaml'; import { computeSha256, getDarLockKey, getDarsDir, loadDarsLock, saveDarsLock } from './dar-utils'; -import { planCandidateBackup, readDeploymentTags } from './dar-version-policy'; +import { planCandidateBackup, readDeploymentState } from './dar-version-policy'; import { getAllPackages, getPackage, parsePackageArg, parseVersionArg } from './packages'; function usage(message?: string): never { @@ -27,6 +27,29 @@ function sdkVersion(sourceDir: string): string { return parsed['sdk-version'] ?? 'unknown'; } +/** Restore a missing/corrupt backup atomically, then verify both its hash and size. */ +export function ensureBackupFile(source: string, destination: string, sha256: string, size: number): boolean { + const valid = + fs.existsSync(destination) && fs.statSync(destination).size === size && computeSha256(destination) === sha256; + if (!valid) { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + const temporary = `${destination}.tmp-${process.pid}`; + try { + fs.copyFileSync(source, temporary); + if (fs.statSync(temporary).size !== size || computeSha256(temporary) !== sha256) { + throw new Error('Temporary DAR copy failed integrity verification'); + } + fs.renameSync(temporary, destination); + } finally { + if (fs.existsSync(temporary)) fs.unlinkSync(temporary); + } + } + if (fs.statSync(destination).size !== size || computeSha256(destination) !== sha256) { + throw new Error('Backed-up DAR failed integrity verification'); + } + return !valid; +} + function main(): void { const packageArg = parsePackageArg(); const version = parseVersionArg(); @@ -42,20 +65,12 @@ function main(): void { const sha256 = computeSha256(source); const { size } = fs.statSync(source); const lock = loadDarsLock(); - const plan = planCandidateBackup(pkg, lock, readDeploymentTags(pkg, root), sha256, size); + const plan = planCandidateBackup(lock, readDeploymentState(root), sha256, size); const key = getDarLockKey(pkg.name, version, pkg.darName); const destination = path.join(getDarsDir(), key); + const restored = ensureBackupFile(source, destination, sha256, size); if (plan.replace) { - fs.mkdirSync(path.dirname(destination), { recursive: true }); - const temporary = `${destination}.tmp-${process.pid}`; - try { - fs.copyFileSync(source, temporary); - if (computeSha256(temporary) !== sha256) throw new Error('Temporary DAR copy failed integrity verification'); - fs.renameSync(temporary, destination); - } finally { - if (fs.existsSync(temporary)) fs.unlinkSync(temporary); - } lock.packages[key] = { sha256, size, @@ -69,13 +84,15 @@ function main(): void { Object.entries(lock.packages).sort(([left], [right]) => left.localeCompare(right)) ); saveDarsLock(lock); - console.log(`${plan.replace ? '✅ Backed up' : '✅ Verified'}: ${key}`); + console.log(`${plan.replace || restored ? '✅ Backed up' : '✅ Verified'}: ${key}`); console.log(` SHA256: ${sha256}`); } -try { - main(); -} catch (error) { - console.error(`❌ ${error instanceof Error ? error.message : String(error)}`); - process.exit(1); +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(`❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } } diff --git a/scripts/check-dar-version-policy.ts b/scripts/check-dar-version-policy.ts index f8e335a0..34c94b93 100644 --- a/scripts/check-dar-version-policy.ts +++ b/scripts/check-dar-version-policy.ts @@ -5,25 +5,22 @@ import * as fs from 'fs'; import * as path from 'path'; import { computeSha256, getDarLockKey, getDarsDir, loadDarsLock, type DarsLock } from './dar-utils'; -import { assertHistoryRetention, assertPackagePolicy, getLockEntry, readDeploymentTags } from './dar-version-policy'; -import { getAllPackages, type PackageConfig } from './packages'; +import { assertHistoryRetention, assertPackagePolicy, getLockEntry, readDeploymentState } from './dar-version-policy'; +import { requirePackageConfig } from './packages'; const ROOT = path.join(__dirname, '..'); -const OCP_SHARED_INPUTS = ['scripts/codegen/', 'libs/splice']; +const pkg = requirePackageConfig('ocp'); function git(args: string[]): string { return execFileSync('git', args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); } -function parseMode(): { all: boolean; base?: string } { +function parseBase(): string | null { const args = process.argv.slice(2); - const all = args.includes('--all'); - const baseIndex = args.indexOf('--base'); - const base = baseIndex >= 0 ? args[baseIndex + 1] : undefined; - if (all === Boolean(base) || (baseIndex >= 0 && !base)) { - throw new Error('Use exactly one of --all or --base '); - } - return { all, base }; + if (args.length === 0) return null; + if (args.length !== 2 || args[0] !== '--base') + throw new Error('Usage: check-dar-version-policy.ts [--base ]'); + return args[1]; } function readLockAt(ref: string): DarsLock { @@ -31,47 +28,44 @@ function readLockAt(ref: string): DarsLock { return JSON.parse(git(['show', `${ref}:dars/dars.lock`])) as DarsLock; } -function changedPackages(base: string): PackageConfig[] { - const mergeBase = git(['merge-base', base, 'HEAD']); - const paths = git(['diff', '--name-only', mergeBase, '--']).split('\n').filter(Boolean); - return getAllPackages().filter( - (pkg) => - paths.some((file) => file.startsWith(`${pkg.sourceDir}/`) || file.startsWith(`dars/${pkg.name}/`)) || - paths.includes('dars/dars.lock') || - paths.some((file) => OCP_SHARED_INPUTS.some((input) => file.startsWith(input))) +function packageInputsChanged(comparisonRef: string): boolean { + const paths = git(['diff', '--name-only', comparisonRef, 'HEAD', '--']).split('\n').filter(Boolean); + return paths.some( + (file) => + file.startsWith(`${pkg.sourceDir}/`) || + file.startsWith(`dars/${pkg.name}/`) || + file === 'dars/dars.lock' || + file.startsWith('scripts/codegen/') || + file.startsWith('libs/splice/') ); } -function checkPackage(pkg: PackageConfig, lock: DarsLock, baseLock?: DarsLock): void { +function main(): void { + const base = parseBase(); + const comparisonRef = base ? git(['merge-base', base, 'HEAD']) : null; + if (comparisonRef && !packageInputsChanged(comparisonRef)) { + console.log('✅ No deployable package inputs changed'); + return; + } + + const lock = loadDarsLock(); const key = getDarLockKey(pkg.name, pkg.version, pkg.darName); const entry = getLockEntry(lock, key); const backup = path.join(getDarsDir(), key); const built = path.join(ROOT, pkg.sourceDir, '.daml', 'dist', `${pkg.darName}-${pkg.version}.dar`); - if (!entry || !fs.existsSync(backup)) throw new Error(`${pkg.name}: current backup is missing (${key})`); - const backupHash = computeSha256(backup); - if (backupHash !== entry.sha256) throw new Error(`${pkg.name}: backup does not match dars.lock (${key})`); - if (!fs.existsSync(built)) throw new Error(`${pkg.name}: build is missing (${path.relative(ROOT, built)})`); - if (computeSha256(built) !== entry.sha256) - throw new Error(`${pkg.name}: fresh build does not match committed backup`); + if (!entry || !fs.existsSync(backup)) throw new Error(`Current backup is missing (${key})`); + if (fs.statSync(backup).size !== entry.size || computeSha256(backup) !== entry.sha256) { + throw new Error(`Current backup does not match dars.lock (${key})`); + } + if (!fs.existsSync(built)) throw new Error(`Fresh build is missing (${path.relative(ROOT, built)})`); + if (computeSha256(built) !== entry.sha256) throw new Error('Fresh build does not match committed backup'); - const tags = readDeploymentTags(pkg, ROOT); - if (baseLock) assertHistoryRetention(pkg, baseLock, lock, tags); - assertPackagePolicy(pkg, lock, tags, entry.sha256); + const state = readDeploymentState(ROOT); + if (comparisonRef) assertHistoryRetention(readLockAt(comparisonRef), lock, state); + assertPackagePolicy(lock, state, entry.sha256); console.log(`✅ ${pkg.name} v${pkg.version}`); } -function main(): void { - const mode = parseMode(); - const lock = loadDarsLock(); - const packages = mode.all ? getAllPackages() : changedPackages(mode.base!); - const baseLock = mode.base ? readLockAt(mode.base) : undefined; - if (packages.length === 0) { - console.log('✅ No deployable package inputs changed'); - return; - } - for (const pkg of packages) checkPackage(pkg, lock, baseLock); -} - try { main(); } catch (error) { diff --git a/scripts/dar-version-policy.test.ts b/scripts/dar-version-policy.test.ts index b20e335d..b2e6a8cc 100644 --- a/scripts/dar-version-policy.test.ts +++ b/scripts/dar-version-policy.test.ts @@ -1,209 +1,136 @@ import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import test from 'node:test'; -import { getDarLockKey, type DarsLock, type DarsLockEntry } from './dar-utils'; +import { ensureBackupFile } from './backup-dar'; +import { computeSha256, type DarsLock, type DarsLockEntry } from './dar-utils'; import { assertDeploymentUpload, assertHistoryRetention, assertPackagePolicy, deploymentTagName, + expectedCandidateVersion, nextPatch, - parseDeploymentTagName, planCandidateBackup, - selectCandidateAnchor, + type DeploymentState, type DeploymentTag, } from './dar-version-policy'; -import type { PackageConfig } from './packages'; import type { ContractNetwork } from './types'; const NAME = 'OpenCapTable-v34'; -const HASH_A = 'a'.repeat(64); -const HASH_B = 'b'.repeat(64); -const HASH_C = 'c'.repeat(64); - -function pkg(version: string): PackageConfig { - return { name: NAME, darName: NAME, sourceDir: NAME, version }; -} +const A = 'a'.repeat(64); +const B = 'b'.repeat(64); +const C = 'c'.repeat(64); function entry(sha256: string, networks: string[] = []): DarsLockEntry { return { sha256, size: 10, sdkVersion: '3.4.10', uploadedAt: '2026-01-01T00:00:00.000Z', networks }; } -function lock(...items: Array<[version: string, sha256: string, networks?: string[], file?: string]>): DarsLock { +function lock(...items: Array<[string, string, string[]?]>): DarsLock { return { version: 1, packages: Object.fromEntries( - items.map(([version, sha256, networks = [], file = `${NAME}.dar`]) => [ - `${NAME}/${version}/${file}`, - entry(sha256, networks), - ]) + items.map(([version, hash, networks = []]) => [`${NAME}/${version}/${NAME}.dar`, entry(hash, networks)]) ), }; } -function tag(network: ContractNetwork, version: string, sha256: string): DeploymentTag { - return { - name: deploymentTagName(network, NAME, version), - network, - packageName: NAME, - version, - sha256, - entry: entry(sha256), - }; +function tag(network: ContractNetwork, version: string, sha256: string, networks: string[] = []): DeploymentTag { + return { name: deploymentTagName(network, version), version, sha256, entry: entry(sha256, networks) }; } -void test('only deployment tags are evidence and patch increments do not roll minor', () => { - assert.equal(parseDeploymentTagName('OpenCapTable-v34-v1.2.3'), null); - assert.deepEqual(parseDeploymentTagName('dar-deploy/devnet/OpenCapTable-v34/v1.2.3'), { - name: 'dar-deploy/devnet/OpenCapTable-v34/v1.2.3', - network: 'devnet', - packageName: NAME, - version: '1.2.3', - }); - assert.equal(nextPatch('1.2.9'), '1.2.10'); -}); - -void test('an absent package starts at 0.0.1 with one candidate', () => { - assert.doesNotThrow(() => assertPackagePolicy(pkg('0.0.1'), lock(['0.0.1', HASH_A]), [], HASH_A)); - assert.throws(() => assertPackagePolicy(pkg('0.0.2'), lock(['0.0.2', HASH_A]), [], HASH_A), /candidate v0\.0\.1/); -}); - -void test('legacy fallback uses the highest nonempty marker and rejects ambiguous hashes', () => { - const history = lock(['0.0.1', HASH_A, ['devnet']], ['0.0.2', HASH_B], ['0.0.3', HASH_C, ['mainnet']]); - assert.deepEqual(selectCandidateAnchor(pkg('0.0.4'), history, []), { - version: '0.0.3', - sha256: HASH_C, - source: 'legacy-marker', - }); - assert.throws( - () => - selectCandidateAnchor( - pkg('0.0.2'), - lock(['0.0.1', HASH_A, ['devnet']], ['0.0.1', HASH_B, ['mainnet'], 'alias.dar']), - [] - ), - /ambiguous legacy deployment hashes/ - ); -}); - -void test('the highest DevNet tag replaces legacy markers as the anchor', () => { - const history = lock(['0.0.1', HASH_A], ['0.0.9', HASH_C, ['mainnet']]); - assert.deepEqual(selectCandidateAnchor(pkg('0.0.2'), history, [tag('devnet', '0.0.1', HASH_A)]), { - version: '0.0.1', - sha256: HASH_A, - source: 'devnet-tag', - }); -}); +function state( + latestDevnet: DeploymentTag | null = null, + currentDevnet: DeploymentTag | null = null, + currentMainnet: DeploymentTag | null = null +): DeploymentState { + return { latestDevnet, currentDevnet, currentMainnet }; +} -void test('only the latest DevNet predecessor must remain in the branch', () => { - const tags = [tag('devnet', '0.0.1', HASH_A), tag('devnet', '0.0.2', HASH_B)]; - assert.doesNotThrow(() => - assertPackagePolicy(pkg('0.0.3'), lock(['0.0.2', HASH_B], ['0.0.3', HASH_C]), tags, HASH_C) - ); -}); +void test('candidate versions come only from the latest DevNet deployment tag', () => { + assert.equal(deploymentTagName('devnet', '1.2.3'), `dar-deploy/devnet/${NAME}/v1.2.3`); + assert.equal(nextPatch('1.2.9'), '1.2.10'); + assert.equal(expectedCandidateVersion(state()), '0.0.1'); + assert.equal(expectedCandidateVersion(state(tag('devnet', '1.2.3', A))), '1.2.4'); -void test('a deployed version is allowed only with its exact tagged bytes', () => { - const tags = [tag('devnet', '0.0.1', HASH_A)]; - assert.doesNotThrow(() => assertPackagePolicy(pkg('0.0.1'), lock(['0.0.1', HASH_A]), tags, HASH_A)); - assert.throws(() => assertPackagePolicy(pkg('0.0.1'), lock(['0.0.1', HASH_B]), tags, HASH_B), /immutable lock entry/); - assert.throws( - () => assertPackagePolicy(pkg('0.0.1'), lock(['0.0.1', HASH_A, ['devnet']]), tags, HASH_A), - /immutable lock entry/ - ); -}); + assert.doesNotThrow(() => assertPackagePolicy(lock(['0.0.1', A, ['mainnet']]), state(), A, '0.0.1')); + assert.throws(() => assertPackagePolicy(lock(['0.0.2', B]), state(), B, '0.0.2'), /candidate v0\.0\.1/); -void test('candidate is exactly one patch above the anchor while uncertain historical rows are retained', () => { - const valid = lock(['0.0.1', HASH_A, ['devnet']], ['0.0.2', HASH_B]); - assert.doesNotThrow(() => assertPackagePolicy(pkg('0.0.2'), valid, [], HASH_B)); - assert.throws( - () => assertPackagePolicy(pkg('0.0.3'), lock(['0.0.1', HASH_A, ['devnet']], ['0.0.3', HASH_C]), [], HASH_C), - /candidate v0\.0\.2/ - ); - assert.doesNotThrow(() => - assertPackagePolicy( - pkg('0.0.2'), - lock(['0.0.1', HASH_A, ['devnet']], ['0.0.2', HASH_B], ['0.0.3', HASH_C]), - [], - HASH_B - ) - ); + const anchor = tag('devnet', '0.0.1', A); + const history = lock(['0.0.1', A], ['0.0.2', B], ['0.0.9', C, ['mainnet']]); + assert.doesNotThrow(() => assertPackagePolicy(history, state(anchor), B, '0.0.2')); + assert.throws(() => assertPackagePolicy(history, state(anchor), B, '0.0.3'), /candidate v0\.0\.2/); }); -void test('backup replaces only the expected candidate and preserves all historical rows', () => { - const history = lock(['0.0.1', HASH_A], ['0.0.2', HASH_B], ['0.0.3', HASH_C], ['0.0.4', HASH_A, ['mainnet']]); - assert.deepEqual(planCandidateBackup(pkg('0.0.2'), history, [tag('devnet', '0.0.1', HASH_A)], HASH_C, 11), { - replace: true, - }); - assert.throws( - () => planCandidateBackup(pkg('0.0.1'), history, [tag('devnet', '0.0.1', HASH_A)], HASH_B, 10), - /cannot be replaced/ - ); +void test('a deployed version must retain its exact tagged bytes and lock row', () => { + const anchor = tag('devnet', '0.0.1', A); + assert.doesNotThrow(() => assertPackagePolicy(lock(['0.0.1', A]), state(anchor, anchor), A, '0.0.1')); + assert.throws(() => assertPackagePolicy(lock(['0.0.1', B]), state(anchor, anchor), B, '0.0.1'), /immutable/); }); -void test('only the canonical current candidate row may change relative to the base tree', () => { - assert.doesNotThrow(() => { - const base = lock(['0.0.1', HASH_A, ['devnet']], ['0.0.2', HASH_B], ['0.0.3', HASH_C]); - const current = lock(['0.0.1', HASH_A, ['devnet']], ['0.0.2', HASH_C], ['0.0.3', HASH_C]); - assertHistoryRetention(pkg('0.0.2'), base, current, []); - }); - assert.throws( - () => assertHistoryRetention(pkg('0.0.2'), lock(['0.0.1', HASH_A, ['devnet']]), lock(['0.0.1', HASH_A, []]), []), - /must be retained/ - ); +void test('base history is immutable except for the unmarked current candidate', () => { + const anchor = tag('devnet', '0.0.1', A, ['devnet']); + const base = lock(['0.0.1', A, ['devnet']], ['0.0.2', B], ['0.0.3', C, ['mainnet']]); + const changedCandidate = lock(['0.0.1', A, ['devnet']], ['0.0.2', C], ['0.0.3', C, ['mainnet']]); + assert.doesNotThrow(() => assertHistoryRetention(base, changedCandidate, state(anchor), '0.0.2')); assert.throws( () => assertHistoryRetention( - pkg('0.0.2'), - lock(['0.0.1', HASH_A, ['devnet']], ['0.0.2', HASH_B]), - lock(['0.0.1', HASH_A, ['devnet']], ['0.0.2', HASH_B, ['devnet']]), - [] + base, + lock(['0.0.1', A, ['devnet']], ['0.0.2', C], ['0.0.3', A, ['mainnet']]), + state(anchor), + '0.0.2' ), /must be retained/ ); assert.throws( - () => - assertHistoryRetention( - pkg('0.0.2'), - lock(['0.0.1', HASH_A, ['devnet']], ['0.0.3', HASH_C]), - lock(['0.0.1', HASH_A, ['devnet']]), - [] - ), + () => assertHistoryRetention(lock(['0.0.1', A, ['devnet']]), lock(['0.0.1', B]), state(), '0.0.1'), /must be retained/ ); }); -void test('Mainnet requires the same-version exact DevNet hash and no newer Mainnet tag', () => { - const current = pkg('0.0.2'); - assert.doesNotThrow(() => assertDeploymentUpload('mainnet', current, HASH_B, [tag('devnet', '0.0.2', HASH_B)])); - assert.throws(() => assertDeploymentUpload('mainnet', current, HASH_B, []), /Mainnet requires/); - assert.throws( - () => assertDeploymentUpload('mainnet', current, HASH_B, [tag('devnet', '0.0.2', HASH_A)]), - /not current hash/ - ); - assert.throws( - () => - assertDeploymentUpload('mainnet', current, HASH_B, [ - tag('devnet', '0.0.2', HASH_B), - tag('mainnet', '0.0.3', HASH_C), - ]), - /Newer Mainnet deployment/ - ); +void test('backup planning replaces only an unmarked candidate', () => { + const anchor = tag('devnet', '0.0.1', A); + assert.deepEqual(planCandidateBackup(lock(['0.0.1', A], ['0.0.2', B]), state(anchor), C, 11, '0.0.2'), { + replace: true, + }); + assert.throws(() => planCandidateBackup(lock(['0.0.1', A]), state(anchor, anchor), B, 10, '0.0.1'), /immutable/); + assert.deepEqual(planCandidateBackup(lock(['0.0.1', A, ['mainnet']]), state(), A, 10, '0.0.1'), { + replace: false, + }); }); -void test('an exact existing tag makes a release rerun idempotent, but a different hash fails', () => { - assert.deepEqual(assertDeploymentUpload('devnet', pkg('0.0.2'), HASH_B, [tag('devnet', '0.0.2', HASH_B)]), { +void test('backup restoration repairs missing and corrupt files with unchanged lock metadata', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dar-backup-')); + try { + const source = path.join(dir, 'source.dar'); + const destination = path.join(dir, 'backup.dar'); + fs.writeFileSync(source, 'fresh DAR bytes'); + const sha256 = computeSha256(source); + const { size } = fs.statSync(source); + assert.equal(ensureBackupFile(source, destination, sha256, size), true); + assert.equal(ensureBackupFile(source, destination, sha256, size), false); + fs.writeFileSync(destination, 'corrupt'); + assert.equal(ensureBackupFile(source, destination, sha256, size), true); + assert.deepEqual([computeSha256(destination), fs.statSync(destination).size], [sha256, size]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +void test('deployment gates are idempotent and Mainnet requires the exact DevNet hash', () => { + assert.deepEqual(assertDeploymentUpload('devnet', B, state(tag('devnet', '0.0.1', A)), '0.0.2'), { + tagExists: false, + }); + const devnet = tag('devnet', '0.0.2', B); + assert.deepEqual(assertDeploymentUpload('devnet', B, state(devnet, devnet), '0.0.2'), { tagExists: true }); + assert.deepEqual(assertDeploymentUpload('mainnet', B, state(devnet, devnet), '0.0.2'), { tagExists: false }); + assert.throws(() => assertDeploymentUpload('mainnet', A, state(), '0.0.1'), /Mainnet requires/); + assert.throws(() => assertDeploymentUpload('mainnet', C, state(devnet, devnet), '0.0.2'), /immutable/); + const mainnet = tag('mainnet', '0.0.2', B); + assert.deepEqual(assertDeploymentUpload('mainnet', B, state(devnet, devnet, mainnet), '0.0.2'), { tagExists: true, }); - assert.throws( - () => assertDeploymentUpload('devnet', pkg('0.0.2'), HASH_B, [tag('devnet', '0.0.2', HASH_A)]), - /not current hash/ - ); - assert.deepEqual( - assertDeploymentUpload('mainnet', pkg('0.0.2'), HASH_B, [ - tag('devnet', '0.0.2', HASH_B), - tag('mainnet', '0.0.2', HASH_B), - ]), - { tagExists: true } - ); - assert.equal(getDarLockKey(NAME, '0.0.2', NAME), `${NAME}/0.0.2/${NAME}.dar`); }); diff --git a/scripts/dar-version-policy.ts b/scripts/dar-version-policy.ts index 103f9d5f..a04290a6 100644 --- a/scripts/dar-version-policy.ts +++ b/scripts/dar-version-policy.ts @@ -3,30 +3,23 @@ import * as fs from 'fs'; import * as path from 'path'; import { computeSha256, getDarLockKey, getDarsDir, loadDarsLock, type DarsLock, type DarsLockEntry } from './dar-utils'; -import { getPackage, parseNetworkArg, parsePackageArg, type PackageConfig } from './packages'; +import { parseNetworkArg, requirePackageConfig } from './packages'; import type { ContractNetwork } from './types'; const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; -const TAG = /^dar-deploy\/(devnet|mainnet)\/([^/]+)\/v(\d+\.\d+\.\d+)$/; - -export interface DarArtifact { - key: string; - version: string; - sha256: string; - entry: DarsLockEntry; -} +const PKG = requirePackageConfig('ocp'); export interface DeploymentTag { name: string; - network: ContractNetwork; - packageName: string; version: string; sha256: string; entry: DarsLockEntry; } -export interface CandidatePlan { - replace: boolean; +export interface DeploymentState { + latestDevnet: DeploymentTag | null; + currentDevnet: DeploymentTag | null; + currentMainnet: DeploymentTag | null; } function semverParts(version: string): [number, number, number] { @@ -35,109 +28,20 @@ function semverParts(version: string): [number, number, number] { return [Number(match[1]), Number(match[2]), Number(match[3])]; } -export function compareSemver(a: string, b: string): number { - const left = semverParts(a); - const right = semverParts(b); - for (let index = 0; index < left.length; index++) { - if (left[index] !== right[index]) return left[index] - right[index]; - } - return 0; -} - export function nextPatch(version: string): string { const [major, minor, patch] = semverParts(version); return `${major}.${minor}.${patch + 1}`; } -export function deploymentTagName(network: ContractNetwork, packageName: string, version: string): string { +export function deploymentTagName(network: ContractNetwork, version: string): string { semverParts(version); - return `dar-deploy/${network}/${packageName}/v${version}`; -} - -export function parseDeploymentTagName(name: string): Omit | null { - const match = TAG.exec(name); - if (!match) return null; - return { - name, - network: match[1] as ContractNetwork, - packageName: match[2], - version: match[3], - }; -} - -export function packageArtifacts(lock: DarsLock, packageName: string): DarArtifact[] { - const prefix = `${packageName}/`; - return Object.entries(lock.packages) - .filter(([key]) => key.startsWith(prefix)) - .map(([key, entry]) => { - const parts = key.split('/'); - if (parts.length !== 3) throw new Error(`Invalid DAR lock key: ${key}`); - semverParts(parts[1]); - return { key, version: parts[1], sha256: entry.sha256, entry }; - }); + return `dar-deploy/${network}/${PKG.name}/v${version}`; } export function getLockEntry(lock: DarsLock, key: string): DarsLockEntry | undefined { return Object.prototype.hasOwnProperty.call(lock.packages, key) ? lock.packages[key] : undefined; } -function git(args: string[], cwd: string): string { - return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); -} - -export function readDeploymentTags(pkg: PackageConfig, cwd = path.join(__dirname, '..')): DeploymentTag[] { - const names = git(['tag', '--list', `dar-deploy/*/${pkg.name}/v*`], cwd) - .split('\n') - .filter(Boolean); - - return names.map((name) => { - const parsed = parseDeploymentTagName(name); - if (parsed?.packageName !== pkg.name) throw new Error(`Invalid DAR deployment tag: ${name}`); - if (git(['cat-file', '-t', `refs/tags/${name}`], cwd) !== 'tag') { - throw new Error(`DAR deployment tag must be annotated: ${name}`); - } - - const taggedLock = JSON.parse(git(['show', `${name}:dars/dars.lock`], cwd)) as DarsLock; - const key = getDarLockKey(pkg.name, parsed.version, pkg.darName); - const entry = getLockEntry(taggedLock, key); - if (!entry) throw new Error(`${name} does not record ${key} in dars/dars.lock`); - return { ...parsed, sha256: entry.sha256, entry }; - }); -} - -function highest(values: T[]): T | null { - return values.reduce( - (best, value) => (!best || compareSemver(value.version, best.version) > 0 ? value : best), - null - ); -} - -export function selectCandidateAnchor( - pkg: PackageConfig, - lock: DarsLock, - tags: DeploymentTag[] -): { version: string; sha256: string; source: 'devnet-tag' | 'legacy-marker' } | null { - const devnet = highest(tags.filter((tag) => tag.network === 'devnet' && tag.packageName === pkg.name)); - if (devnet) return { version: devnet.version, sha256: devnet.sha256, source: 'devnet-tag' }; - - const marked = packageArtifacts(lock, pkg.name).filter((artifact) => artifact.entry.networks.length > 0); - const latest = highest(marked); - if (!latest) return null; - const hashes = new Set( - marked.filter((artifact) => artifact.version === latest.version).map((artifact) => artifact.sha256) - ); - if (hashes.size !== 1) throw new Error(`${pkg.name} v${latest.version} has ambiguous legacy deployment hashes`); - return { version: latest.version, sha256: latest.sha256, source: 'legacy-marker' }; -} - -function isDeployed(artifact: DarArtifact, pkg: PackageConfig, tags: DeploymentTag[]): boolean { - if (artifact.entry.networks.length > 0) return true; - const canonical = getDarLockKey(pkg.name, artifact.version, pkg.darName); - return ( - artifact.key === canonical && tags.some((tag) => tag.version === artifact.version && tag.sha256 === artifact.sha256) - ); -} - function sameEntry(left: DarsLockEntry | undefined, right: DarsLockEntry): boolean { if (!left) return false; return ( @@ -150,136 +54,187 @@ function sameEntry(left: DarsLockEntry | undefined, right: DarsLockEntry): boole ); } -function assertLatestDevnetPresent(pkg: PackageConfig, lock: DarsLock, tags: DeploymentTag[]): void { - const tag = highest(tags.filter((item) => item.network === 'devnet' && item.packageName === pkg.name)); +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +function readTag(name: string, network: ContractNetwork, cwd: string): DeploymentTag | null { + try { + git(['show-ref', '--verify', '--quiet', `refs/tags/${name}`], cwd); + } catch (error) { + if ((error as { status?: number }).status === 1) return null; + throw error; + } + const prefix = `dar-deploy/${network}/${PKG.name}/v`; + if (!name.startsWith(prefix)) throw new Error(`Invalid DAR deployment tag: ${name}`); + if (git(['cat-file', '-t', `refs/tags/${name}`], cwd) !== 'tag') { + throw new Error(`DAR deployment tag must be annotated: ${name}`); + } + const version = name.slice(prefix.length); + semverParts(version); + + const taggedLock = JSON.parse(git(['show', `${name}:dars/dars.lock`], cwd)) as DarsLock; + const key = getDarLockKey(PKG.name, version, PKG.darName); + const entry = getLockEntry(taggedLock, key); + if (!entry) throw new Error(`${name} does not record ${key} in dars/dars.lock`); + return { name, version, sha256: entry.sha256, entry }; +} + +/** Read only the latest DevNet tag and the two exact tags relevant to the current version. */ +export function readDeploymentState(cwd = path.join(__dirname, '..')): DeploymentState { + const latestName = git( + [ + 'for-each-ref', + '--sort=-version:refname', + '--count=1', + '--format=%(refname:short)', + `refs/tags/dar-deploy/devnet/${PKG.name}/v*`, + ], + cwd + ); + const currentDevnetName = deploymentTagName('devnet', PKG.version); + const currentMainnetName = deploymentTagName('mainnet', PKG.version); + const latestDevnet = latestName ? readTag(latestName, 'devnet', cwd) : null; + + return { + latestDevnet, + currentDevnet: latestName === currentDevnetName ? latestDevnet : readTag(currentDevnetName, 'devnet', cwd), + currentMainnet: readTag(currentMainnetName, 'mainnet', cwd), + }; +} + +export function expectedCandidateVersion(state: DeploymentState): string { + return state.latestDevnet ? nextPatch(state.latestDevnet.version) : '0.0.1'; +} + +function assertLatestDevnetEntry(lock: DarsLock, state: DeploymentState): void { + const tag = state.latestDevnet; if (!tag) return; - const key = getDarLockKey(pkg.name, tag.version, pkg.darName); + const key = getDarLockKey(PKG.name, tag.version, PKG.darName); if (!sameEntry(getLockEntry(lock, key), tag.entry)) { throw new Error(`${tag.name} records an immutable lock entry, but current ${key} differs`); } } +function resolveCandidate(state: DeploymentState, sha256: string, version: string): void { + const anchor = state.latestDevnet; + const expected = expectedCandidateVersion(state); + if (anchor?.version === version) { + if (sha256 !== anchor.sha256) throw new Error(`${PKG.name} v${version} is deployed and its bytes are immutable`); + return; + } + if (version !== expected) { + throw new Error( + `${PKG.name} must remain byte-identical at ${anchor ? `deployed v${anchor.version}` : 'its initial version'} or use candidate v${expected}; found v${version}` + ); + } +} + +export function assertPackagePolicy( + lock: DarsLock, + state: DeploymentState, + currentHash: string, + version = PKG.version +): void { + assertLatestDevnetEntry(lock, state); + resolveCandidate(state, currentHash, version); + const currentKey = getDarLockKey(PKG.name, version, PKG.darName); + if (getLockEntry(lock, currentKey)?.sha256 !== currentHash) { + throw new Error(`Current candidate is not locked: ${currentKey}`); + } +} + export function assertHistoryRetention( - pkg: PackageConfig, base: DarsLock, current: DarsLock, - tags: DeploymentTag[] + state: DeploymentState, + version = PKG.version ): void { - const anchor = selectCandidateAnchor(pkg, current, tags); - const candidateVersion = anchor ? nextPatch(anchor.version) : '0.0.1'; - const mutableKey = pkg.version === candidateVersion ? getDarLockKey(pkg.name, pkg.version, pkg.darName) : null; + const mutableKey = version === expectedCandidateVersion(state) ? getDarLockKey(PKG.name, version, PKG.darName) : null; + const prefix = `${PKG.name}/`; const keys = new Set( - [...packageArtifacts(base, pkg.name), ...packageArtifacts(current, pkg.name)].map((item) => item.key) + [...Object.keys(base.packages), ...Object.keys(current.packages)].filter((key) => key.startsWith(prefix)) ); + for (const key of keys) { const baseEntry = getLockEntry(base, key); const currentEntry = getLockEntry(current, key); - const mutableCandidate = key === mutableKey && !baseEntry?.networks.length && !currentEntry?.networks.length; - if (mutableCandidate) continue; - if (!baseEntry || !sameEntry(currentEntry, baseEntry)) + const mutableCandidate = + key === mutableKey && currentEntry?.networks.length === 0 && (baseEntry?.networks.length ?? 0) === 0; + if (!mutableCandidate && (!baseEntry || !sameEntry(currentEntry, baseEntry))) { throw new Error(`Historical DAR lock entry must be retained: ${key}`); + } } } -export function assertPackagePolicy( - pkg: PackageConfig, - lock: DarsLock, - tags: DeploymentTag[], - currentHash: string -): void { - assertLatestDevnetPresent(pkg, lock, tags); - const anchor = selectCandidateAnchor(pkg, lock, tags); - const expected = anchor ? nextPatch(anchor.version) : '0.0.1'; - const unchangedDeployment = anchor !== null && pkg.version === anchor.version && currentHash === anchor.sha256; - if (!unchangedDeployment && pkg.version !== expected) { - throw new Error( - `${pkg.name} must remain byte-identical at deployed ${anchor ? `v${anchor.version}` : 'version none'} or use candidate v${expected}; found v${pkg.version}` - ); - } - if (anchor?.version === pkg.version && currentHash !== anchor.sha256) { - throw new Error(`${pkg.name} v${pkg.version} is deployed and its bytes are immutable`); - } - - const currentKey = getDarLockKey(pkg.name, pkg.version, pkg.darName); - if (getLockEntry(lock, currentKey)?.sha256 !== currentHash) - throw new Error(`Current candidate is not locked: ${currentKey}`); -} - export function planCandidateBackup( - pkg: PackageConfig, lock: DarsLock, - tags: DeploymentTag[], + state: DeploymentState, sha256: string, - size: number -): CandidatePlan { - assertLatestDevnetPresent(pkg, lock, tags); - const anchor = selectCandidateAnchor(pkg, lock, tags); - const expected = anchor ? nextPatch(anchor.version) : '0.0.1'; - if (anchor?.version === pkg.version) { - if (sha256 !== anchor.sha256) throw new Error(`${pkg.name} v${pkg.version} is deployed and cannot be replaced`); - } else if (pkg.version !== expected) { - throw new Error(`${pkg.name} candidate must be v${expected}; found v${pkg.version}`); - } - - const targetKey = getDarLockKey(pkg.name, pkg.version, pkg.darName); - const target = packageArtifacts(lock, pkg.name).find((artifact) => artifact.key === targetKey); - for (const tag of tags.filter((item) => item.version === pkg.version)) { - if (tag.sha256 !== sha256 || !sameEntry(getLockEntry(lock, targetKey), tag.entry)) { - throw new Error(`Deployed DAR is immutable: ${targetKey}`); + size: number, + version = PKG.version +): { replace: boolean } { + assertLatestDevnetEntry(lock, state); + resolveCandidate(state, sha256, version); + + const key = getDarLockKey(PKG.name, version, PKG.darName); + const target = getLockEntry(lock, key); + const tags = [state.currentDevnet, state.currentMainnet].filter((tag): tag is DeploymentTag => tag !== null); + const immutable = tags.length > 0 || Boolean(target?.networks.length); + if (immutable) { + if (!target) throw new Error(`Deployed DAR is missing from dars.lock: ${key}`); + if (target.sha256 !== sha256 || target.size !== size || tags.some((tag) => tag.sha256 !== sha256)) { + throw new Error(`Deployed DAR is immutable: ${key}`); } + return { replace: false }; } - if (target && isDeployed(target, pkg, tags) && target.sha256 !== sha256) { - throw new Error(`Deployed DAR is immutable: ${targetKey}`); - } - return { - replace: !target || (!isDeployed(target, pkg, tags) && (target.sha256 !== sha256 || target.entry.size !== size)), - }; + + if (!target) return { replace: true }; + return { replace: target.sha256 !== sha256 || target.size !== size }; } export function assertDeploymentUpload( network: ContractNetwork, - pkg: PackageConfig, sha256: string, - tags: DeploymentTag[] + state: DeploymentState, + version = PKG.version ): { tagExists: boolean } { - const exact = tags.find((tag) => tag.network === network && tag.version === pkg.version); + resolveCandidate(state, sha256, version); + const exact = network === 'devnet' ? state.currentDevnet : state.currentMainnet; if (exact && exact.sha256 !== sha256) { throw new Error(`${exact.name} records ${exact.sha256}, not current hash ${sha256}`); } - - if (network === 'devnet') { - const newer = tags.find((tag) => tag.network === 'devnet' && compareSemver(tag.version, pkg.version) > 0); - if (newer) throw new Error(`Newer DevNet deployment already exists: ${newer.name}`); - if (exact) return { tagExists: true }; - return { tagExists: false }; + if (network === 'mainnet') { + const devnet = state.currentDevnet; + if (!devnet) throw new Error(`Mainnet requires ${deploymentTagName('devnet', version)}`); + if (devnet.sha256 !== sha256) { + throw new Error(`${devnet.name} records ${devnet.sha256}, not current hash ${sha256}`); + } } - - const devnet = tags.find((tag) => tag.network === 'devnet' && tag.version === pkg.version); - if (!devnet) throw new Error(`Mainnet requires ${deploymentTagName('devnet', pkg.name, pkg.version)}`); - if (devnet.sha256 !== sha256) throw new Error(`${devnet.name} records ${devnet.sha256}, not current hash ${sha256}`); - const newer = tags.find((tag) => tag.network === 'mainnet' && compareSemver(tag.version, pkg.version) > 0); - if (newer) throw new Error(`Newer Mainnet deployment already exists: ${newer.name}`); - if (exact) return { tagExists: true }; - return { tagExists: false }; + return { tagExists: exact !== null }; } function main(): void { - const pkg = getPackage(parsePackageArg() ?? ''); const network = parseNetworkArg(); - if (!pkg || !network) - throw new Error('Usage: tsx scripts/dar-version-policy.ts --package --network '); + if (!network) throw new Error('Usage: tsx scripts/dar-version-policy.ts --network '); const lock = loadDarsLock(); - const key = getDarLockKey(pkg.name, pkg.version, pkg.darName); + const key = getDarLockKey(PKG.name, PKG.version, PKG.darName); const entry = getLockEntry(lock, key); const darPath = path.join(getDarsDir(), key); - if (!entry || !fs.existsSync(darPath) || computeSha256(darPath) !== entry.sha256) { + if ( + !entry || + !fs.existsSync(darPath) || + fs.statSync(darPath).size !== entry.size || + computeSha256(darPath) !== entry.sha256 + ) { throw new Error(`Current locked DAR is missing or invalid: ${key}`); } - const result = assertDeploymentUpload(network, pkg, entry.sha256, readDeploymentTags(pkg)); - if (process.env.GITHUB_OUTPUT) + const result = assertDeploymentUpload(network, entry.sha256, readDeploymentState()); + if (process.env.GITHUB_OUTPUT) { fs.appendFileSync(process.env.GITHUB_OUTPUT, `tag_exists=${String(result.tagExists)}\n`); + } console.log( - `✅ ${network} deployment gate passed for ${pkg.name} v${pkg.version} (${entry.sha256}); tag_exists=${result.tagExists}` + `✅ ${network} deployment gate passed for ${PKG.name} v${PKG.version} (${entry.sha256}); tag_exists=${result.tagExists}` ); }