From e6b89790c6b8f83157ef9fda2c1a67062923a7fd Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Mon, 13 Jul 2026 17:11:35 -0400 Subject: [PATCH] Use successful deployment tags for DAR versions --- .github/workflows/ci.yml | 14 +- .github/workflows/release.yml | 63 +++++- package.json | 3 + scripts/backup-dar.ts | 158 +++++---------- scripts/check-dar-version-policy.ts | 80 ++++++++ scripts/dar-utils.ts | 30 --- scripts/dar-version-policy.test.ts | 209 ++++++++++++++++++++ scripts/dar-version-policy.ts | 293 ++++++++++++++++++++++++++++ scripts/upload-dar.ts | 54 ++--- 9 files changed, 725 insertions(+), 179 deletions(-) create mode 100644 scripts/check-dar-version-policy.ts create mode 100644 scripts/dar-version-policy.test.ts create mode 100644 scripts/dar-version-policy.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39e908b5..1c9eeee0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 with: + fetch-depth: 0 submodules: false lfs: true # PAT_TOKEN (repo secret): checkout + later push use same creds so autofix commits trigger workflows. @@ -23,6 +24,11 @@ jobs: git lfs install git lfs pull + - name: Fetch deployment tags + run: | + git fetch --no-tags origin main:refs/remotes/origin/main + git fetch --tags origin + - name: Setup Java uses: actions/setup-java@v5 with: @@ -40,6 +46,9 @@ jobs: - name: Install npm dependencies run: npm install + - name: Test DAR version policy + run: npm run test:dar-version-policy + - name: Check pinned dependencies run: npx --yes --package @hardlydifficult/ci-scripts@1.0.85 check-pinned-deps @@ -66,6 +75,9 @@ jobs: - name: Build DAML run: npm run build + - name: Check DAR version policy + run: npm run check:dar-version-policy -- --base origin/main + - name: Lint DAML run: npm run lint:daml @@ -96,7 +108,7 @@ jobs: fi - name: Commit and push auto-fixes - if: steps.check-changes.outputs.has_changes == 'true' && github.event_name == 'push' + if: steps.check-changes.outputs.has_changes == 'true' && github.event_name == 'push' && github.ref_type == 'branch' env: PAT_TOKEN: ${{ secrets.PAT_TOKEN }} run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26430053..09e8e287 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ on: type: string concurrency: - group: package-release-${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} + group: package-release-ocp cancel-in-progress: false jobs: @@ -48,6 +48,9 @@ jobs: git lfs install git lfs pull + - name: Fetch deployment tags + run: git fetch --tags origin + - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: @@ -114,12 +117,21 @@ jobs: run: scripts/install-dpm-sdks.sh - name: Validate npm package version + id: npm_version run: | PACKAGE_NAME=$(node -p "require('./package.json').name") PACKAGE_VERSION=$(node -p "require('./package.json').version") if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then - echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} is already published. Bump package.json before creating a package release tag." - exit 1 + devnet_tag="dar-deploy/devnet/${{ steps.release_tag.outputs.package }}/v${{ steps.release_tag.outputs.version }}" + mainnet_tag="dar-deploy/mainnet/${{ steps.release_tag.outputs.package }}/v${{ steps.release_tag.outputs.version }}" + if ! git show-ref --verify --quiet "refs/tags/${devnet_tag}" || ! git show-ref --verify --quiet "refs/tags/${mainnet_tag}"; then + echo "::error::${PACKAGE_NAME}@${PACKAGE_VERSION} is already published without both deployment success tags. Refusing a new release over an existing npm version." + exit 1 + fi + echo "${PACKAGE_NAME}@${PACKAGE_VERSION} and both deployment tags already exist; resuming post-publish finalization." + echo "already_published=true" >> "$GITHUB_OUTPUT" + else + echo "already_published=false" >> "$GITHUB_OUTPUT" fi - name: Validate release tag is current main @@ -134,7 +146,8 @@ jobs: - name: Build and validate package run: | npm run build - npm run backup-dar -- --package "${{ steps.release_tag.outputs.package_key }}" --version "${{ steps.release_tag.outputs.version }}" + npm run test:dar-version-policy + npm run check:dar-version-policy -- --all npm run lint npm run format npm run lint:daml @@ -143,12 +156,46 @@ jobs: npm run verify-package npm run test + - name: Check DevNet deployment + id: devnet_gate + run: npm run check:dar-deployment -- --package "${{ steps.release_tag.outputs.package_key }}" --network devnet + - name: Upload DAR to devnet + if: steps.devnet_gate.outputs.tag_exists != 'true' run: npm run upload-dar -- --package "${{ steps.release_tag.outputs.package_key }}" --network devnet + - name: Record DevNet deployment tag + if: steps.devnet_gate.outputs.tag_exists != 'true' + env: + PACKAGE_NAME: ${{ steps.release_tag.outputs.package }} + 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" + 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 + - name: Upload DAR to mainnet + if: steps.mainnet_gate.outputs.tag_exists != 'true' run: npm run upload-dar -- --package "${{ steps.release_tag.outputs.package_key }}" --network mainnet + - name: Record Mainnet deployment tag + if: steps.mainnet_gate.outputs.tag_exists != 'true' + env: + PACKAGE_NAME: ${{ steps.release_tag.outputs.package }} + 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)') + git tag -a "$tag" -m "Deployed ${PACKAGE_NAME} v${PACKAGE_VERSION} to Mainnet (${sha})" + git push --no-verify origin "refs/tags/$tag" + - name: Detect factory state id: factory run: | @@ -176,6 +223,7 @@ jobs: run: npm run package:manifest - name: Publish npm package + if: steps.npm_version.outputs.already_published != 'true' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish @@ -191,8 +239,11 @@ jobs: 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 + fi for path in \ - dars \ generated/ocp-factory-contract-id.json \ generated/ocp-factory-contract-id.json.d.ts \ generated/npm-manifest.txt \ @@ -210,4 +261,4 @@ jobs: fi git commit -m "chore: record release artifacts for ${RELEASE_TAG} [skip ci]" - git push origin HEAD:main + git push --no-verify origin HEAD:main diff --git a/package.json b/package.json index 6051514c..59e9744f 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,8 @@ "build": "tsx scripts/codegen/generate-captable.ts && PATH=\"$HOME/.dpm/bin:$PATH\" dpm build --all", "build:npm-runtime-lib": "tsc -p tsconfig.npm-runtime.json", "build:ts": "tsc", + "check:dar-deployment": "tsx scripts/dar-version-policy.ts", + "check:dar-version-policy": "tsx scripts/check-dar-version-policy.ts", "check:schema-gaps": "tsx scripts/schema-gap-checker/check-schema-gaps.ts", "check-upgrade-compat": "tsx scripts/check-upgrade-compatibility.ts", "clean": "for pkg in OpenCapTable-v34 Test; do (cd $pkg && PATH=\"$HOME/.dpm/bin:$PATH\" dpm clean); done", @@ -79,6 +81,7 @@ "prereplay-ocf:localnet": "tsx scripts/link-local-ocp-bindings.ts", "replay-ocf:localnet": "tsx scripts/replay-ocf-database-on-localnet.ts", "test": "cd Test && PATH=\"$HOME/.dpm/bin:$PATH\" dpm test --show-coverage --color --coverage-ignore-choice splice-amulet:.*", + "test:dar-version-policy": "tsx --test scripts/dar-version-policy.test.ts", "test:imports": "tsx scripts/test-imports.ts", "test:replay-ocf": "tsx scripts/replay-ocf-database-on-localnet.test.ts", "update-version": "tsx scripts/update-generated-package.ts", diff --git a/scripts/backup-dar.ts b/scripts/backup-dar.ts index f29a2092..9723d610 100644 --- a/scripts/backup-dar.ts +++ b/scripts/backup-dar.ts @@ -1,131 +1,81 @@ #!/usr/bin/env node -/** - * Backup a DAR file after mainnet upload. Copies from .daml/dist/ to dars/{package}/{version}/ and updates dars.lock. - * - * **Retention:** When bumping a package version, add the new backup with this script but **do not remove** prior - * `dars///` trees that are already in the repo—keep historical DARs for audit, re-upload, and - * debugging. See the repo wiki: https://github.com/Fairmint/open-captable-protocol-daml/wiki - * - * Usage: tsx scripts/backup-dar.ts --package --version [--network ] - */ +/** Replace the one undeployed candidate backup while preserving every deployed DAR. */ import * as fs from 'fs'; import * as path from 'path'; import * as yaml from 'yaml'; -import { computeSha256, getDarsDir, loadDarsLock, saveDarsLock, type DarsLockEntry } from './dar-utils'; -import { getAllPackages, getPackage, parseNetworkArg, parsePackageArg, parseVersionArg } from './packages'; -function printUsage(errorMessage?: string): never { - if (errorMessage) console.error(`❌ ${errorMessage}\n`); - console.error('Usage: tsx scripts/backup-dar.ts --package --version [--network ]'); - console.error('\nPackages:'); - for (const pkg of getAllPackages()) { - console.error(` ${pkg.name}`); - } +import { computeSha256, getDarLockKey, getDarsDir, loadDarsLock, saveDarsLock } from './dar-utils'; +import { planCandidateBackup, readDeploymentTags } from './dar-version-policy'; +import { getAllPackages, getPackage, parsePackageArg, parseVersionArg } from './packages'; + +function usage(message?: string): never { + if (message) console.error(`❌ ${message}\n`); + console.error('Usage: tsx scripts/backup-dar.ts --package --version '); + console.error( + `Packages: ${getAllPackages() + .map((pkg) => pkg.name) + .join(', ')}` + ); process.exit(1); } -function getSdkVersion(sourceDir: string): string { - const damlYamlPath = path.join(__dirname, '..', sourceDir, 'daml.yaml'); - if (!fs.existsSync(damlYamlPath)) return 'unknown'; - - try { - const content = fs.readFileSync(damlYamlPath, 'utf-8'); - const parsed = yaml.parse(content); - return parsed?.['sdk-version'] ?? 'unknown'; - } catch { - return 'unknown'; - } +function sdkVersion(sourceDir: string): string { + const parsed = yaml.parse(fs.readFileSync(path.join(__dirname, '..', sourceDir, 'daml.yaml'), 'utf8')) as { + 'sdk-version'?: string; + }; + return parsed['sdk-version'] ?? 'unknown'; } -function main() { +function main(): void { const packageArg = parsePackageArg(); const version = parseVersionArg(); - const network = parseNetworkArg(); - - if (!packageArg || !version) printUsage('Missing required arguments'); - + if (!packageArg || !version) usage('Missing required arguments'); const pkg = getPackage(packageArg); - if (!pkg) printUsage(`Unknown package: ${packageArg}`); + if (!pkg) usage(`Unknown package: ${packageArg}`); + if (version !== pkg.version) usage(`daml.yaml is v${pkg.version}, not v${version}`); - const rootDir = path.join(__dirname, '..'); - const darsDir = getDarsDir(); - - // Build paths - const sourcePath = path.join(rootDir, pkg.sourceDir, '.daml', 'dist', `${pkg.darName}-${version}.dar`); - const destDir = path.join(darsDir, pkg.name, version); - const destPath = path.join(destDir, `${pkg.darName}.dar`); - const lockKey = `${pkg.name}/${version}/${pkg.darName}.dar`; - - // Check source exists - if (!fs.existsSync(sourcePath)) { - console.error(`❌ Source DAR not found: ${sourcePath}`); - console.error(' Run "npm run build" first'); - process.exit(1); - } + const root = path.join(__dirname, '..'); + const source = path.join(root, pkg.sourceDir, '.daml', 'dist', `${pkg.darName}-${version}.dar`); + if (!fs.existsSync(source)) usage(`Source DAR not found: ${source}. Run npm run build first.`); + const sha256 = computeSha256(source); + const { size } = fs.statSync(source); const lock = loadDarsLock(); - - // Already backed up? - if (lockKey in lock.packages) { - console.log(`ℹ️ Already backed up: ${lockKey}`); - - if (!fs.existsSync(destPath)) { - console.error(`❌ Lock entry exists but file missing: ${destPath}`); - process.exit(1); + const plan = planCandidateBackup(pkg, lock, readDeploymentTags(pkg, root), sha256, size); + const key = getDarLockKey(pkg.name, version, pkg.darName); + const destination = path.join(getDarsDir(), key); + + 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); } - - // Verify hash - const hash = computeSha256(destPath); - if (hash !== lock.packages[lockKey].sha256) { - console.error(`❌ Hash mismatch! File may have been modified.`); - process.exit(1); - } - - // Add network if specified - if (network && !lock.packages[lockKey].networks.includes(network)) { - lock.packages[lockKey].networks.push(network); - lock.packages[lockKey].networks.sort(); - saveDarsLock(lock); - console.log(`✅ Added network: ${network}`); - } - return; + lock.packages[key] = { + sha256, + size, + sdkVersion: sdkVersion(pkg.sourceDir), + uploadedAt: new Date().toISOString(), + networks: [], + }; } - // Create backup - fs.mkdirSync(destDir, { recursive: true }); - fs.copyFileSync(sourcePath, destPath); - - const hash = computeSha256(destPath); - const stats = fs.statSync(destPath); - - lock.packages[lockKey] = { - sha256: hash, - size: stats.size, - sdkVersion: getSdkVersion(pkg.sourceDir), - uploadedAt: new Date().toISOString(), - networks: network ? [network] : [], - }; - - // Sort for consistent ordering - const sorted: Record = {}; - Object.keys(lock.packages) - .sort() - .forEach((k) => { - sorted[k] = lock.packages[k]; - }); - lock.packages = sorted; - + lock.packages = Object.fromEntries( + Object.entries(lock.packages).sort(([left], [right]) => left.localeCompare(right)) + ); saveDarsLock(lock); - - console.log(`✅ Backed up: ${lockKey}`); - console.log(` SHA256: ${hash}`); - console.log(` Size: ${stats.size} bytes`); + console.log(`${plan.replace ? '✅ Backed up' : '✅ Verified'}: ${key}`); + console.log(` SHA256: ${sha256}`); } try { main(); -} catch (err) { - console.error('❌ Error:', err); +} 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 new file mode 100644 index 00000000..f8e335a0 --- /dev/null +++ b/scripts/check-dar-version-policy.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +import { execFileSync } from 'child_process'; +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'; + +const ROOT = path.join(__dirname, '..'); +const OCP_SHARED_INPUTS = ['scripts/codegen/', 'libs/splice']; + +function git(args: string[]): string { + return execFileSync('git', args, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +function parseMode(): { all: boolean; base?: string } { + 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 }; +} + +function readLockAt(ref: string): DarsLock { + git(['cat-file', '-e', `${ref}^{commit}`]); + 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 checkPackage(pkg: PackageConfig, lock: DarsLock, baseLock?: DarsLock): void { + 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`); + + const tags = readDeploymentTags(pkg, ROOT); + if (baseLock) assertHistoryRetention(pkg, baseLock, lock, tags); + assertPackagePolicy(pkg, lock, tags, 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) { + console.error(`❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} diff --git a/scripts/dar-utils.ts b/scripts/dar-utils.ts index 4aad937d..b95d9306 100644 --- a/scripts/dar-utils.ts +++ b/scripts/dar-utils.ts @@ -202,16 +202,6 @@ export function requireBackedUpDar(packageName: string, version: string, darName process.exit(1); } -/** Check if a DAR has been backed up. */ -export function isDarBackedUp(packageName: string, version: string, darName: string): boolean { - const lockKey = getDarLockKey(packageName, version, darName); - const lock = loadDarsLock(); - if (!(lockKey in lock.packages)) return false; - - const darPath = path.join(getDarsDir(), lockKey); - return fs.existsSync(darPath); -} - /** * @deprecated Use requireBackedUpDar instead to ensure backups are mandatory. Get the path to a DAR file, preferring * backed-up version over fresh build. Returns the backed-up DAR path if available and verified, otherwise falls back @@ -249,23 +239,3 @@ export function getDarPath(packageName: string, version: string, darName: string return freshPath; } - -/** Record that a DAR was uploaded to a specific network. Updates the networks array in dars.lock. */ -export function recordNetworkUpload(packageName: string, version: string, darName: string, network: string): void { - const lockKey = getDarLockKey(packageName, version, darName); - const lock = loadDarsLock(); - - // Only update if entry exists - if (!(lockKey in lock.packages)) { - console.log(`ℹ️ DAR not backed up yet, skipping network record for ${lockKey} (${network})`); - return; - } - - const entry = lock.packages[lockKey]; - if (!entry.networks.includes(network)) { - entry.networks.push(network); - entry.networks.sort(); - saveDarsLock(lock); - console.log(`📝 Recorded ${network} upload in dars.lock`); - } -} diff --git a/scripts/dar-version-policy.test.ts b/scripts/dar-version-policy.test.ts new file mode 100644 index 00000000..b20e335d --- /dev/null +++ b/scripts/dar-version-policy.test.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { getDarLockKey, type DarsLock, type DarsLockEntry } from './dar-utils'; +import { + assertDeploymentUpload, + assertHistoryRetention, + assertPackagePolicy, + deploymentTagName, + nextPatch, + parseDeploymentTagName, + planCandidateBackup, + selectCandidateAnchor, + 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 }; +} + +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 { + return { + version: 1, + packages: Object.fromEntries( + items.map(([version, sha256, networks = [], file = `${NAME}.dar`]) => [ + `${NAME}/${version}/${file}`, + entry(sha256, networks), + ]) + ), + }; +} + +function tag(network: ContractNetwork, version: string, sha256: string): DeploymentTag { + return { + name: deploymentTagName(network, NAME, version), + network, + packageName: NAME, + version, + sha256, + entry: entry(sha256), + }; +} + +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', + }); +}); + +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('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/ + ); +}); + +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 + ) + ); +}); + +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('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/ + ); + 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']]), + [] + ), + /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']]), + [] + ), + /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('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)]), { + 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 new file mode 100644 index 00000000..103f9d5f --- /dev/null +++ b/scripts/dar-version-policy.ts @@ -0,0 +1,293 @@ +import { execFileSync } from 'child_process'; +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 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; +} + +export interface DeploymentTag { + name: string; + network: ContractNetwork; + packageName: string; + version: string; + sha256: string; + entry: DarsLockEntry; +} + +export interface CandidatePlan { + replace: boolean; +} + +function semverParts(version: string): [number, number, number] { + const match = SEMVER.exec(version); + if (!match) throw new Error(`Invalid semantic version: ${version}`); + 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 { + 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 }; + }); +} + +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 ( + left.sha256 === right.sha256 && + left.size === right.size && + left.sdkVersion === right.sdkVersion && + left.uploadedAt === right.uploadedAt && + left.networks.length === right.networks.length && + left.networks.every((network, index) => network === right.networks[index]) + ); +} + +function assertLatestDevnetPresent(pkg: PackageConfig, lock: DarsLock, tags: DeploymentTag[]): void { + const tag = highest(tags.filter((item) => item.network === 'devnet' && item.packageName === pkg.name)); + if (!tag) return; + 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`); + } +} + +export function assertHistoryRetention( + pkg: PackageConfig, + base: DarsLock, + current: DarsLock, + tags: DeploymentTag[] +): 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 keys = new Set( + [...packageArtifacts(base, pkg.name), ...packageArtifacts(current, pkg.name)].map((item) => item.key) + ); + 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)) + 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[], + 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}`); + } + } + 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)), + }; +} + +export function assertDeploymentUpload( + network: ContractNetwork, + pkg: PackageConfig, + sha256: string, + tags: DeploymentTag[] +): { tagExists: boolean } { + const exact = tags.find((tag) => tag.network === network && tag.version === pkg.version); + 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 }; + } + + 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 }; +} + +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 '); + const lock = loadDarsLock(); + 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) { + 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) + 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}` + ); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(`❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} diff --git a/scripts/upload-dar.ts b/scripts/upload-dar.ts index f5460681..80958a1a 100644 --- a/scripts/upload-dar.ts +++ b/scripts/upload-dar.ts @@ -2,8 +2,7 @@ /** * Upload a DAR file to devnet or mainnet. * - * Requires the DAR to be backed up first. If not backed up, the script will automatically run the backup process before - * uploading. + * Requires a fresh build that exactly matches the committed candidate backup. * * **Backed-up DARs:** Upload uses the version recorded under `dars/` + `dars.lock`. Older versions remain in `dars/` on * purpose—see https://github.com/Fairmint/open-captable-protocol-daml/wiki @@ -16,42 +15,12 @@ * ` (with Canton's **ALLOW_VET_INCOMPATIBLE_UPGRADES** force flag) to vet the new package id. */ -import { execSync } from 'child_process'; import * as fs from 'fs'; -import { getFreshDarPath, isDarBackedUp, recordNetworkUpload, requireBackedUpDar } from './dar-utils'; +import { computeSha256, getFreshDarPath, requireBackedUpDar } from './dar-utils'; import { parseNetworkArg, parsePackageArg, printPackageUsage, requireNetwork, requirePackage } from './packages'; import { LEDGER_SCRIPT_PROVIDERS } from './providers'; import { createLedgerJsonApiClient } from './utils'; -/** Ensure the DAR is backed up before upload. If not backed up, automatically run the backup process. */ -function ensureDarBackedUp(packageName: string, version: string, darName: string): void { - if (isDarBackedUp(packageName, version, darName)) { - return; - } - - // Check if fresh DAR exists to backup - const freshPath = getFreshDarPath(packageName, version, darName); - if (!freshPath) { - console.error(`❌ No DAR found to backup`); - console.error(` Expected: ${packageName}/.daml/dist/${darName}-${version}.dar`); - console.error(` Run "npm run build" first to build the DAR.`); - process.exit(1); - } - - console.log(`📋 DAR not backed up yet, backing up first...\n`); - - try { - execSync(`npm run backup-dar -- --package ${packageName} --version ${version}`, { - stdio: 'inherit', - cwd: process.cwd(), - }); - console.log(''); - } catch { - console.error(`\n❌ Failed to backup DAR`); - process.exit(1); - } -} - async function main() { // Validate args (show help if missing) if (!parsePackageArg() || !parseNetworkArg()) { @@ -64,11 +33,21 @@ async function main() { console.log(`\n📦 Uploading ${pkg.name} v${pkg.version} to ${network}\n`); - // Ensure DAR is backed up first (auto-backup if needed) - ensureDarBackedUp(pkg.name, pkg.version, pkg.darName); - - // Now require the backed-up DAR (this verifies integrity) + // Upload only the committed backup, after proving the fresh build is byte-identical. const darPath = requireBackedUpDar(pkg.name, pkg.version, pkg.darName); + const freshPath = getFreshDarPath(pkg.sourceDir, pkg.version, pkg.darName); + if (!freshPath) { + console.error(`❌ Fresh DAR not found. Run "npm run build" first.`); + process.exit(1); + } + const backupHash = computeSha256(darPath); + const freshHash = computeSha256(freshPath); + if (freshHash !== backupHash) { + console.error(`❌ Fresh build does not match committed backup.`); + console.error(` Backup: ${backupHash}`); + console.error(` Build: ${freshHash}`); + process.exit(1); + } // Upload to each provider independently so one unhealthy participant (e.g. devnet Intellect with no synchronizer) // does not block the other. @@ -122,7 +101,6 @@ async function main() { process.exit(1); } - recordNetworkUpload(pkg.name, pkg.version, pkg.darName, network); console.log(`\n🎉 Upload complete\n`); }