Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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

Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare main pushes against the previous commit

When this workflow runs for a push to main, the preceding fetch step updates refs/remotes/origin/main to the commit that is currently being built (git fetch accepts the explicit main:refs/remotes/origin/main refspec). Passing that same ref as --base makes changedPackages() use HEAD as the merge base and see an empty diff, so direct main pushes can skip the DAR version policy entirely. Use github.event.before for main pushes or run the policy with --all in that context.

Useful? React with 👍 / 👎.


- name: Lint DAML
run: npm run lint:daml

Expand Down Expand Up @@ -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: |
Expand Down
63 changes: 57 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid running the full migration audit during release

In the committed migration state, OpenCapTable-v34/daml.yaml is 0.0.3 while dars/dars.lock only marks 0.0.1 as deployed unless a dar-deploy/devnet/.../v0.0.2 tag already exists. Running --all here invokes the policy before any upload and requires the candidate to be 0.0.2, so a normal 0.0.3 release is blocked in that state. Gate only the release package against the release tag/tags, or require the migration tag before enabling this release step.

Useful? React with 👍 / 👎.

npm run lint
npm run format
npm run lint:daml
Expand All @@ -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)')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Read dars.lock as JSON before tagging deployments

When the deployment tag is missing, this command runs after the upload succeeds, but require("./dars/dars.lock") is not parsed as JSON because .lock is not a registered Node JSON extension; locally, node -e 'require("./dars/dars.lock")' fails with Unexpected token ':'. That means both fresh DevNet and Mainnet releases upload the DAR and then fail before recording the success tag, so reruns cannot become idempotent until this reads the file with fs.readFileSync/JSON.parse or a .json path.

Useful? React with 👍 / 👎.

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})"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Configure Git before mainnet-only tag creation

When rerunning after DevNet succeeded and pushed dar-deploy/devnet/... but Mainnet did not get tagged, steps.devnet_gate.outputs.tag_exists is true, so the DevNet record step (the only place that runs git config user.name/user.email) is skipped. The Mainnet record step still creates an annotated tag with git tag -a (confirmed via git tag -h: -a creates an annotated tag), and in a clean runner without a configured identity this fails with Git's unable to auto-detect email address, blocking recovery from a partial release. Configure Git in this step too, or before both tag-creation steps.

Useful? React with 👍 / 👎.

git push --no-verify origin "refs/tags/$tag"

- name: Detect factory state
id: factory
run: |
Expand Down Expand Up @@ -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
Expand All @@ -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 \
Expand All @@ -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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
158 changes: 54 additions & 104 deletions scripts/backup-dar.ts
Original file line number Diff line number Diff line change
@@ -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/<package>/<oldVersion>/` 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 <name> --version <version> [--network <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 <name> --version <version> [--network <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 <name> --version <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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the backup file when the lock entry matches

If a current candidate already has a matching dars.lock entry but the DAR file was deleted or corrupted, plan.replace is false and this branch skips both copying the fresh build and verifying destination, then reports ✅ Verified. That breaks the documented recovery path for the current backup is missing / dars.lock lists ... but the file is missing failures, because rerunning npm run backup-dar will not recreate the file until the lock hash or size changes.

Useful? React with 👍 / 👎.

fs.mkdirSync(path.dirname(destination), { recursive: true });
const temporary = `${destination}.tmp-${process.pid}`;
try {
fs.copyFileSync(source, temporary);
Comment on lines +49 to +53
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<string, DarsLockEntry> = {};
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}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backup skips missing file check

Medium Severity

When planCandidateBackup returns replace: false, backup-dar prints Verified and saves dars.lock without confirming the backup file exists on disk or matches the lock hash. A lock entry with a missing or corrupt file is no longer detected or restored.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e6b8979. Configure here.

Comment on lines +45 to +73
}

try {
main();
} catch (err) {
console.error('❌ Error:', err);
} catch (error) {
console.error(`❌ ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
Loading
Loading