diff --git a/.github/workflows/deploy-col-mar-mm.yml b/.github/workflows/deploy-col-mar-mm.yml index 8a35597..c415034 100644 --- a/.github/workflows/deploy-col-mar-mm.yml +++ b/.github/workflows/deploy-col-mar-mm.yml @@ -62,8 +62,50 @@ env: GHCR_IMAGE: ghcr.io/lumerin-protocol/collateral-margin-market-maker jobs: + config-scope: + name: Config scope + runs-on: ubuntu-latest + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Skip the other environment's config + id: gate + run: | + # dev does not deploy when the only change is config/prd.env. + # main does not deploy when the only change is config/dev.env. + # Any other file still deploys. A manual run always deploys. + REF="${{ github.event_name == 'pull_request' && github.base_ref || github.ref_name }}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch --no-tags origin "$REF" + FILES=$(git diff --name-only "origin/${REF}...HEAD") + elif git cat-file -e "${{ github.event.before }}^{commit}" 2>/dev/null; then + FILES=$(git diff --name-only "${{ github.event.before }}" HEAD) + else + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + SKIP="config/prd.env" + [ "$REF" = "main" ] && SKIP="config/dev.env" + RUN=true + if [ -n "$FILES" ] && [ -z "$(printf '%s\n' "$FILES" | grep -vx "$SKIP" || true)" ]; then + RUN=false + fi + echo "run=$RUN" >> "$GITHUB_OUTPUT" + echo "ref=$REF skip=$SKIP run=$RUN" + printf '%s\n' "$FILES" + build: name: ๐Ÿ”จ Build + needs: config-scope + if: needs.config-scope.result == 'success' && needs.config-scope.outputs.run == 'true' runs-on: ubuntu-latest outputs: version: ${{ steps.gen_tag.outputs.version }} @@ -372,7 +414,7 @@ jobs: name: ๐Ÿงน Cleanup runs-on: ubuntu-latest needs: [ build, verify ] - if: always() && needs.build.outputs.is_cicd_branch != 'true' + if: always() && needs.build.result != 'skipped' && needs.build.outputs.is_cicd_branch != 'true' steps: - name: Checkout code diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml index 24fae31..ce38522 100644 --- a/.github/workflows/deploy-keeper.yml +++ b/.github/workflows/deploy-keeper.yml @@ -58,8 +58,50 @@ env: GHCR_IMAGE: ghcr.io/lumerin-protocol/collateral-margin-keeper jobs: + config-scope: + name: Config scope + runs-on: ubuntu-latest + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Skip the other environment's config + id: gate + run: | + # dev does not deploy when the only change is config/prd.env. + # main does not deploy when the only change is config/dev.env. + # Any other file still deploys. A manual run always deploys. + REF="${{ github.event_name == 'pull_request' && github.base_ref || github.ref_name }}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch --no-tags origin "$REF" + FILES=$(git diff --name-only "origin/${REF}...HEAD") + elif git cat-file -e "${{ github.event.before }}^{commit}" 2>/dev/null; then + FILES=$(git diff --name-only "${{ github.event.before }}" HEAD) + else + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + SKIP="config/prd.env" + [ "$REF" = "main" ] && SKIP="config/dev.env" + RUN=true + if [ -n "$FILES" ] && [ -z "$(printf '%s\n' "$FILES" | grep -vx "$SKIP" || true)" ]; then + RUN=false + fi + echo "run=$RUN" >> "$GITHUB_OUTPUT" + echo "ref=$REF skip=$SKIP run=$RUN" + printf '%s\n' "$FILES" + build: name: ๐Ÿ”จ Build + needs: config-scope + if: needs.config-scope.result == 'success' && needs.config-scope.outputs.run == 'true' runs-on: ubuntu-latest outputs: version: ${{ steps.gen_tag.outputs.version }} @@ -453,7 +495,7 @@ jobs: name: ๐Ÿงน Cleanup runs-on: ubuntu-latest needs: [build, verify] - if: always() && needs.build.outputs.is_cicd_branch != 'true' + if: always() && needs.build.result != 'skipped' && needs.build.outputs.is_cicd_branch != 'true' steps: - name: Checkout code diff --git a/.github/workflows/deploy-points-subgraph.yml b/.github/workflows/deploy-points-subgraph.yml index 6c5d9a1..eaf3fef 100644 --- a/.github/workflows/deploy-points-subgraph.yml +++ b/.github/workflows/deploy-points-subgraph.yml @@ -48,8 +48,50 @@ env: SERVICE_NAME: points-subgraph jobs: + config-scope: + name: Config scope + runs-on: ubuntu-latest + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Skip the other environment's config + id: gate + run: | + # dev does not deploy when the only change is config/prd.env. + # main does not deploy when the only change is config/dev.env. + # Any other file still deploys. A manual run always deploys. + REF="${{ github.event_name == 'pull_request' && github.base_ref || github.ref_name }}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch --no-tags origin "$REF" + FILES=$(git diff --name-only "origin/${REF}...HEAD") + elif git cat-file -e "${{ github.event.before }}^{commit}" 2>/dev/null; then + FILES=$(git diff --name-only "${{ github.event.before }}" HEAD) + else + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + SKIP="config/prd.env" + [ "$REF" = "main" ] && SKIP="config/dev.env" + RUN=true + if [ -n "$FILES" ] && [ -z "$(printf '%s\n' "$FILES" | grep -vx "$SKIP" || true)" ]; then + RUN=false + fi + echo "run=$RUN" >> "$GITHUB_OUTPUT" + echo "ref=$REF skip=$SKIP run=$RUN" + printf '%s\n' "$FILES" + setup: name: ๐Ÿ”ง Setup + needs: config-scope + if: needs.config-scope.result == 'success' && needs.config-scope.outputs.run == 'true' runs-on: ubuntu-latest outputs: environment: ${{ steps.gen_tag.outputs.environment }} @@ -115,17 +157,18 @@ jobs: fi echo "goldsky_subgraph_name=$GS_NAME" >> $GITHUB_OUTPUT - # Rolling tag, public endpoint, and the config/.env to load. + # Rolling tag and which config file to load. The public query URL + # is POINTS_SUBGRAPH_URL in that file. case $ENV in dev) echo "goldsky_rolling_tag=dev-latest" >> $GITHUB_OUTPUT - echo "goldsky_endpoint=${{ vars.DEV_GS_POINTS }}" >> $GITHUB_OUTPUT echo "config_env=dev" >> $GITHUB_OUTPUT + CONFIG_ENV=dev ;; main) echo "goldsky_rolling_tag=lmn-latest" >> $GITHUB_OUTPUT - echo "goldsky_endpoint=${{ vars.LMN_GS_POINTS }}" >> $GITHUB_OUTPUT echo "config_env=prd" >> $GITHUB_OUTPUT + CONFIG_ENV=prd ;; *) echo "::error::Unknown environment '$ENV' (expected dev or main)" @@ -133,6 +176,15 @@ jobs: ;; esac + if [ -n "${CONFIG_ENV:-}" ]; then + ENDPOINT=$(grep -E '^POINTS_SUBGRAPH_URL=' "config/${CONFIG_ENV}.env" | head -1 | cut -d= -f2-) + if [ -z "$ENDPOINT" ]; then + echo "::error::POINTS_SUBGRAPH_URL is missing from config/${CONFIG_ENV}.env" + exit 1 + fi + echo "goldsky_endpoint=$ENDPOINT" >> "$GITHUB_OUTPUT" + fi + echo "๐ŸŽฏ Deploying to Goldsky: subgraph=${GS_NAME}, env=${ENV}" - name: Install dependencies @@ -348,7 +400,7 @@ jobs: if [ -z "${GOLDSKY_ENDPOINT}" ]; then echo "โš ๏ธ No Goldsky endpoint URL configured โ€” skipping verification" - echo " Set DEV_GS_POINTS / LMN_GS_POINTS org variable" + echo " Set POINTS_SUBGRAPH_URL in config/dev.env or config/prd.env" exit 0 fi @@ -393,7 +445,7 @@ jobs: name: ๐Ÿงน Cleanup runs-on: ubuntu-latest needs: [setup, build, verify] - if: always() && github.event_name != 'pull_request' + if: always() && needs.setup.result != 'skipped' && github.event_name != 'pull_request' steps: - name: Checkout code @@ -440,7 +492,7 @@ jobs: name: ๐Ÿ“ข Notify runs-on: ubuntu-latest needs: [setup, build, deploy, verify, cleanup] - if: always() && github.event_name != 'pull_request' + if: always() && needs.setup.result != 'skipped' && github.event_name != 'pull_request' steps: - name: Checkout (for composite action) diff --git a/.github/workflows/deploy-subgraph.yml b/.github/workflows/deploy-subgraph.yml index eb219a2..f6fb2da 100644 --- a/.github/workflows/deploy-subgraph.yml +++ b/.github/workflows/deploy-subgraph.yml @@ -46,8 +46,50 @@ env: SERVICE_NAME: collateral-vault-subgraph jobs: + config-scope: + name: Config scope + runs-on: ubuntu-latest + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Skip the other environment's config + id: gate + run: | + # dev does not deploy when the only change is config/prd.env. + # main does not deploy when the only change is config/dev.env. + # Any other file still deploys. A manual run always deploys. + REF="${{ github.event_name == 'pull_request' && github.base_ref || github.ref_name }}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch --no-tags origin "$REF" + FILES=$(git diff --name-only "origin/${REF}...HEAD") + elif git cat-file -e "${{ github.event.before }}^{commit}" 2>/dev/null; then + FILES=$(git diff --name-only "${{ github.event.before }}" HEAD) + else + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + SKIP="config/prd.env" + [ "$REF" = "main" ] && SKIP="config/dev.env" + RUN=true + if [ -n "$FILES" ] && [ -z "$(printf '%s\n' "$FILES" | grep -vx "$SKIP" || true)" ]; then + RUN=false + fi + echo "run=$RUN" >> "$GITHUB_OUTPUT" + echo "ref=$REF skip=$SKIP run=$RUN" + printf '%s\n' "$FILES" + setup: name: ๐Ÿ”ง Setup + needs: config-scope + if: needs.config-scope.result == 'success' && needs.config-scope.outputs.run == 'true' runs-on: ubuntu-latest outputs: environment: ${{ steps.gen_tag.outputs.environment }} @@ -113,17 +155,18 @@ jobs: fi echo "goldsky_subgraph_name=$GS_NAME" >> $GITHUB_OUTPUT - # Rolling tag, public endpoint, and the config/.env to load. + # Rolling tag and which config file to load. The public query URL + # is VAULT_SUBGRAPH_URL in that file. case $ENV in dev) echo "goldsky_rolling_tag=dev-latest" >> $GITHUB_OUTPUT - echo "goldsky_endpoint=${{ vars.DEV_GS_VAULT }}" >> $GITHUB_OUTPUT echo "config_env=dev" >> $GITHUB_OUTPUT + CONFIG_ENV=dev ;; main) echo "goldsky_rolling_tag=lmn-latest" >> $GITHUB_OUTPUT - echo "goldsky_endpoint=${{ vars.LMN_GS_VAULT }}" >> $GITHUB_OUTPUT echo "config_env=prd" >> $GITHUB_OUTPUT + CONFIG_ENV=prd ;; *) echo "::error::Unknown environment '$ENV' (expected dev or main)" @@ -131,6 +174,15 @@ jobs: ;; esac + if [ -n "${CONFIG_ENV:-}" ]; then + ENDPOINT=$(grep -E '^VAULT_SUBGRAPH_URL=' "config/${CONFIG_ENV}.env" | head -1 | cut -d= -f2-) + if [ -z "$ENDPOINT" ]; then + echo "::error::VAULT_SUBGRAPH_URL is missing from config/${CONFIG_ENV}.env" + exit 1 + fi + echo "goldsky_endpoint=$ENDPOINT" >> "$GITHUB_OUTPUT" + fi + echo "๐ŸŽฏ Deploying to Goldsky: subgraph=${GS_NAME}, env=${ENV}" - name: Install dependencies @@ -346,7 +398,7 @@ jobs: if [ -z "${GOLDSKY_ENDPOINT}" ]; then echo "โš ๏ธ No Goldsky endpoint URL configured โ€” skipping verification" - echo " Set DEV_GS_VAULT / LMN_GS_VAULT org variable" + echo " Set VAULT_SUBGRAPH_URL in config/dev.env or config/prd.env" exit 0 fi @@ -391,7 +443,7 @@ jobs: name: ๐Ÿงน Cleanup runs-on: ubuntu-latest needs: [setup, build, verify] - if: always() && github.event_name != 'pull_request' + if: always() && needs.setup.result != 'skipped' && github.event_name != 'pull_request' steps: - name: Checkout code @@ -438,7 +490,7 @@ jobs: name: ๐Ÿ“ข Notify runs-on: ubuntu-latest needs: [setup, build, deploy, verify, cleanup] - if: always() && github.event_name != 'pull_request' + if: always() && needs.setup.result != 'skipped' && github.event_name != 'pull_request' steps: - name: Checkout (for composite action) diff --git a/.github/workflows/publish-collateral-abi.yml b/.github/workflows/publish-collateral-abi.yml index df5f75c..92a9465 100644 --- a/.github/workflows/publish-collateral-abi.yml +++ b/.github/workflows/publish-collateral-abi.yml @@ -1,12 +1,12 @@ name: Publish @hashpower/collateral-abi -# Publishes the ABI package to npm whenever the generated ABIs or the -# deployments manifest change on main. Versioning is automatic and -# semver-correct: the ABI *is* the package's public API, so CI diffs the -# built ABI surface against the last published version to compute the bump -# (removed/changed entry -> major, added entry -> minor, metadata -> patch) -# โ€” demoted while the published major is 0, per semver's 0.x convention -# (breaking -> minor, additive -> patch) โ€” and publishes with that version. Nothing is committed back โ€” the branch is +# Publishes the ABI package to npm. `dev` writes environments.testnet from +# config/dev.env, keeps the mainnet block from the `latest` tag, publishes +# on the npm dist-tag `dev`, and bumps the minor. `main` writes +# environments.mainnet from config/prd.env, keeps the testnet block from +# the `dev` tag, and publishes on `latest`. The first mainnet cut is +# 1.0.0; later mainnet address changes are patches, and an ABI break on +# main bumps the major. Nothing is committed back โ€” the branch is # protected โ€” so the version of record lives on npm and each release is # marked with a git tag (collateral-abi-vX.Y.Z) on the source commit. # @@ -26,6 +26,8 @@ on: - main paths: - "contracts/abi/**" + - "config/dev.env" + - "config/prd.env" - "collateral-abi/**" - ".github/workflows/publish-collateral-abi.yml" # Manual runs from any branch for testing the pipeline. `version` publishes @@ -69,8 +71,10 @@ jobs: with: node-version: "24" registry-url: "https://registry.npmjs.org" - cache: "pnpm" - cache-dependency-path: collateral-abi/pnpm-lock.yaml + # No pnpm cache. setup-node's post step fails the whole job with + # "Path Validation Error" when the store path it recorded is gone, + # including after a skipped publish. This package has one + # devDependency, so the cache is not worth a red release. - name: Install dependencies run: pnpm install --frozen-lockfile @@ -78,70 +82,23 @@ jobs: - name: Build package run: pnpm build - - name: Compute semver bump from ABI diff + - name: Compose deployments manifest + env: + CHANNEL: ${{ github.ref_name }} + run: node scripts/compose-deployments.mjs + + - name: Compute version and dist-tag id: bump env: + CHANNEL: ${{ github.ref_name }} FORCED_VERSION: ${{ inputs.version || '' }} - run: | - if [ -n "$FORCED_VERSION" ]; then - npm version "$FORCED_VERSION" --no-git-tag-version --allow-same-version - echo "Forced version: $FORCED_VERSION (semver diff skipped)" - echo "level=forced" >> "$GITHUB_OUTPUT" - echo "version=$FORCED_VERSION" >> "$GITHUB_OUTPUT" - exit 0 - fi - - PKG=$(node -p "require('./package.json').name") - PUBLISHED=$(npm view "$PKG" version 2>/dev/null || echo "none") - - if [ "$PUBLISHED" = "none" ]; then - echo "First publish โ€” using version from package.json as-is" - echo "level=first" >> "$GITHUB_OUTPUT" - echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Fetch the published tarball and diff its ABI surface against the fresh build - TARBALL=$(npm pack "$PKG@$PUBLISHED" --silent | tail -1) - mkdir -p /tmp/published - tar -xzf "$TARBALL" -C /tmp/published - rm "$TARBALL" - - LEVEL=$(node scripts/semver-diff.mjs /tmp/published/package .) - echo "Published: $PUBLISHED โ€” ABI diff requires: $LEVEL" - - # Pre-1.0 the package is explicitly unstable (semver 0.x rules), so - # demote: breaking -> minor, additive -> patch. Cutting 1.0.0 at GA - # (from main) ends the demotion automatically. - if [ "${PUBLISHED%%.*}" = "0" ]; then - case "$LEVEL" in - major) LEVEL=minor ;; - minor) LEVEL=patch ;; - esac - echo "0.x pre-release line โ€” demoted bump to: $LEVEL" - fi - echo "level=$LEVEL" >> "$GITHUB_OUTPUT" - - if [ "$LEVEL" = "none" ]; then - echo "No ABI or metadata changes โ€” skipping publish" - echo "version=$PUBLISHED" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Baseline on the published version, then apply the computed bump. - # Working-copy only: dev is protected (PRs required) so CI never - # pushes commits. npm holds the version of record. - npm version "$PUBLISHED" --no-git-tag-version --allow-same-version - npm version "$LEVEL" --no-git-tag-version - NEW=$(node -p "require('./package.json').version") - echo "version=$NEW" >> "$GITHUB_OUTPUT" - echo "Version: $NEW ($LEVEL bump from $PUBLISHED)" + run: node scripts/plan-release.mjs - name: Publish if: steps.bump.outputs.level != 'none' - # --tag latest is explicit so a forced lower version (reset/rollback) - # can still take the latest tag; npm forbids that implicitly. - run: npm publish --access public --provenance --tag latest + # `dev` publishes must not move `latest`. A forced version follows + # the same dist-tag as the branch it was dispatched from. + run: npm publish --access public --provenance --tag ${{ steps.bump.outputs.dist_tag }} - name: Tag release if: steps.bump.outputs.level != 'none' diff --git a/collateral-abi/README.md b/collateral-abi/README.md index c6fb5ca..f79ac41 100644 --- a/collateral-abi/README.md +++ b/collateral-abi/README.md @@ -37,4 +37,4 @@ Contents are generated โ€” do not edit by hand: - `src/` is copied from `../contracts/abi` (the Hardhat codegen output) by `scripts/build.mjs`, then compiled to `dist/`. - `deployments.json` is the canonical address manifest for this repo; it is updated when contracts are (re)deployed. -Publishing happens automatically from CI when ABIs or the manifest change (see `.github/workflows/publish-collateral-abi.yml`). +Publishing happens from CI (see `.github/workflows/publish-collateral-abi.yml`). A `dev` publish updates `environments.testnet` and is tagged `dev` (minor bump). A `main` publish updates `environments.mainnet` and is tagged `latest`; the first mainnet cut is `3.0.0`, skipping any major npm has already used. diff --git a/collateral-abi/deployment-sources.json b/collateral-abi/deployment-sources.json new file mode 100644 index 0000000..08afecf --- /dev/null +++ b/collateral-abi/deployment-sources.json @@ -0,0 +1,16 @@ +{ + "envs": { + "testnet": { "file": "../config/dev.env", "chainId": 84532, "network": "base-sepolia" }, + "mainnet": { "file": "../config/prd.env", "chainId": 8453, "network": "base" } + }, + "contracts": { + "CollateralVault": "VAULT_ADDRESS", + "PortfolioMarginEngine": "PME_ADDRESS", + "Points": "POINTS_ADDRESS", + "CollateralToken": "COLLATERAL_TOKEN_ADDRESS" + }, + "subgraphs": { + "vault": "VAULT_SUBGRAPH_URL", + "points": "POINTS_SUBGRAPH_URL" + } +} diff --git a/collateral-abi/scripts/compose-deployments.mjs b/collateral-abi/scripts/compose-deployments.mjs new file mode 100644 index 0000000..dbf5bc9 --- /dev/null +++ b/collateral-abi/scripts/compose-deployments.mjs @@ -0,0 +1,129 @@ +// Rebuild deployments.json for the branch that is about to publish. +// +// dev owns environments.testnet (config/dev.env) and copies mainnet +// forward from the npm `latest` tag. +// main owns environments.mainnet (config/prd.env) and copies testnet +// forward from the npm `dev` tag (falling back to `latest`). +// +// Unmapped contract keys already in this environment (for example +// HashrateOracleLegacy) are kept. An address that only changed checksum +// casing keeps the spelling already in the file. +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sources = JSON.parse(readFileSync(path.join(root, "deployment-sources.json"), "utf8")); +const current = JSON.parse(readFileSync(path.join(root, "deployments.json"), "utf8")); +const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).name; + +function parseEnv(file) { + const values = {}; + for (const line of readFileSync(file, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq < 0) continue; + values[trimmed.slice(0, eq)] = trimmed.slice(eq + 1); + } + return values; +} + +function sameAddress(left, right) { + return /^0x[0-9a-fA-F]{40}$/.test(left) && left.toLowerCase() === right.toLowerCase(); +} + +function overlay(prior, updates) { + const next = { ...(prior ?? {}) }; + for (const [name, value] of Object.entries(updates)) { + if (!value) continue; + const existing = next[name]; + next[name] = existing && sameAddress(existing, value) ? existing : value; + } + return next; +} + +function ownedSnapshot(envName) { + const spec = sources.envs[envName]; + const env = parseEnv(path.resolve(root, spec.file)); + if (env.CHAIN_ID && Number(env.CHAIN_ID) !== spec.chainId) { + throw new Error(`${spec.file} CHAIN_ID ${env.CHAIN_ID} does not match ${spec.chainId} for ${envName}`); + } + const prior = current.environments?.[envName] ?? {}; + const contracts = overlay( + prior.contracts, + Object.fromEntries( + Object.entries(sources.contracts).map(([name, key]) => [name, env[key]]), + ), + ); + const subgraphs = overlay( + prior.subgraphs, + Object.fromEntries( + Object.entries(sources.subgraphs ?? {}).map(([name, key]) => [name, env[key]]), + ), + ); + const snapshot = { + chainId: spec.chainId, + network: spec.network, + contracts, + subgraphs, + }; + if (prior.startBlock != null) snapshot.startBlock = prior.startBlock; + return snapshot; +} + +function distTags() { + try { + return JSON.parse(execFileSync("npm", ["view", pkg, "dist-tags", "--json"], { encoding: "utf8" })); + } catch { + return {}; + } +} + +function publishedManifest(version) { + if (!version) return null; + const dir = mkdtempSync(path.join(tmpdir(), "abi-published-")); + try { + const packed = execFileSync( + "npm", + ["pack", `${pkg}@${version}`, "--silent", "--pack-destination", dir], + { encoding: "utf8" }, + ) + .trim() + .split("\n") + .pop(); + execFileSync("tar", ["-xzf", path.join(dir, packed), "-C", dir]); + return JSON.parse(readFileSync(path.join(dir, "package", "deployments.json"), "utf8")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const channel = process.env.CHANNEL === "main" ? "main" : "dev"; +const owned = channel === "main" ? "mainnet" : "testnet"; +const other = owned === "mainnet" ? "testnet" : "mainnet"; +const tags = distTags(); +const otherVersion = other === "mainnet" ? tags.latest : tags.dev || tags.latest; +let otherSnapshot = current.environments?.[other] ?? { chainId: sources.envs[other].chainId, network: sources.envs[other].network, contracts: {}, subgraphs: {} }; +if (otherVersion) { + const published = publishedManifest(otherVersion); + if (published?.environments?.[other]) otherSnapshot = published.environments[other]; +} + +const environments = { + testnet: owned === "testnet" ? ownedSnapshot("testnet") : otherSnapshot, + mainnet: owned === "mainnet" ? ownedSnapshot("mainnet") : otherSnapshot, +}; +// Keep startBlock beside the other identity fields when the owned snapshot has one. +for (const name of ["testnet", "mainnet"]) { + const env = environments[name]; + if (env.startBlock == null) continue; + const { chainId, network, startBlock, contracts, subgraphs } = env; + environments[name] = { chainId, network, startBlock, contracts, subgraphs }; +} + +const next = { package: current.package ?? pkg, environments }; +writeFileSync(path.join(root, "deployments.json"), `${JSON.stringify(next, null, 2)}\n`); +console.log(`Composed ${owned} from config and copied ${other} from ${otherVersion ? `${pkg}@${otherVersion}` : "the committed manifest"}`); diff --git a/collateral-abi/scripts/plan-release.mjs b/collateral-abi/scripts/plan-release.mjs new file mode 100644 index 0000000..a630c6f --- /dev/null +++ b/collateral-abi/scripts/plan-release.mjs @@ -0,0 +1,202 @@ +// Decide the version and npm dist-tag for this publish. +// +// dev -> dist-tag `dev`, bump minor for an ABI or testnet-manifest change. +// The base is the `dev` tag, or the highest published 0.x if that +// tag does not exist yet. Never moves `latest`. +// main -> dist-tag `latest`. The first publish whose mainnet block has +// addresses is 3.0.0, or the next free major if that version was +// already published (npm will not reuse an unpublished version). +// After that, an ABI break bumps major, an ABI addition bumps +// minor, and a mainnet address change bumps patch. +// +// Writes package.json's version in the working copy (npm holds the version +// of record; nothing is committed). Set DRY_RUN=1 to print the decision only. +import { execFileSync } from "node:child_process"; +import { appendFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).name; +const dryRun = process.env.DRY_RUN === "1"; + +function emit(fields) { + const lines = Object.entries(fields).map(([key, value]) => `${key}=${value}`); + for (const line of lines) console.log(line); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`); +} + +function canonDeploy(value) { + if (typeof value === "string" && /^0x[0-9a-fA-F]{40}$/.test(value)) return value.toLowerCase(); + if (Array.isArray(value)) return `[${value.map(canonDeploy).join(",")}]`; + if (value && typeof value === "object") { + const keys = Object.keys(value).sort(); + return `{${keys.map((key) => `${JSON.stringify(key)}:${canonDeploy(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function npmJson(args) { + return JSON.parse(execFileSync("npm", args, { encoding: "utf8" })); +} + +function distTags() { + try { + return npmJson(["view", pkg, "dist-tags", "--json"]); + } catch { + return {}; + } +} + +function versionsOf() { + try { + const parsed = npmJson(["view", pkg, "versions", "--json"]); + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return []; + } +} + +// `npm view versions` hides unpublished releases. `time` still lists them, +// and npm rejects a publish that reuses one of those version numbers. +function takenVersions() { + try { + const time = npmJson(["view", pkg, "time", "--json"]); + return new Set(Object.keys(time).filter((key) => /^\d+\.\d+\.\d+$/.test(key))); + } catch { + return new Set(versionsOf()); + } +} + +function firstMainnetVersion() { + const taken = takenVersions(); + let major = 3; + while (taken.has(`${major}.0.0`)) major += 1; + return `${major}.0.0`; +} + +function parts(version) { + return version.split(".").map((part) => Number(part)); +} + +function cmp(left, right) { + const a = parts(left); + const b = parts(right); + for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i]; + return 0; +} + +function hasAddresses(env) { + return Object.values(env?.contracts ?? {}).some((value) => /^0x[0-9a-fA-F]{40}$/.test(value)); +} + +function extract(version) { + const dir = mkdtempSync(path.join(tmpdir(), "abi-plan-")); + const packed = execFileSync("npm", ["pack", `${pkg}@${version}`, "--silent", "--pack-destination", dir], { + encoding: "utf8", + }) + .trim() + .split("\n") + .pop(); + execFileSync("tar", ["-xzf", path.join(dir, packed), "-C", dir]); + return { dir, pkgRoot: path.join(dir, "package") }; +} + +function setVersion(version) { + if (dryRun) return; + execFileSync("npm", ["version", version, "--no-git-tag-version", "--allow-same-version"], { cwd: root, stdio: "inherit" }); +} + +function bump(version, level) { + if (dryRun) { + const [x, y, z] = parts(version); + if (level === "major") return `${x + 1}.0.0`; + if (level === "minor") return `${x}.${y + 1}.0`; + if (level === "patch") return `${x}.${y}.${z + 1}`; + return version; + } + setVersion(version); + execFileSync("npm", ["version", level, "--no-git-tag-version"], { cwd: root, stdio: "inherit" }); + return JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version; +} + +const channel = process.env.CHANNEL === "main" ? "main" : "dev"; +const distTag = channel === "main" ? "latest" : "dev"; +const owned = channel === "main" ? "mainnet" : "testnet"; +const forced = process.env.FORCED_VERSION || ""; + +if (forced) { + setVersion(forced); + console.log(`Forced version ${forced} on dist-tag ${distTag}`); + emit({ level: "forced", version: forced, dist_tag: distTag }); + process.exit(0); +} + +const tags = distTags(); +const current = JSON.parse(readFileSync(path.join(root, "deployments.json"), "utf8")); + +if (channel === "main" && !hasAddresses(current.environments?.mainnet)) { + console.log("Mainnet addresses are empty โ€” not publishing to latest"); + emit({ level: "none", version: tags.latest || "", dist_tag: distTag }); + process.exit(0); +} + +if (!tags.latest) { + const version = channel === "main" ? firstMainnetVersion() : JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version; + setVersion(version); + console.log(`First publish ${pkg}@${version} (${distTag})`); + emit({ level: "first", version, dist_tag: distTag }); + process.exit(0); +} + +if (channel === "main") { + const latest = extract(tags.latest); + try { + const published = JSON.parse(readFileSync(path.join(latest.pkgRoot, "deployments.json"), "utf8")); + if (!hasAddresses(published.environments?.mainnet)) { + const version = firstMainnetVersion(); + setVersion(version); + console.log(`First mainnet manifest โ€” publishing ${pkg}@${version} on latest`); + emit({ level: "mainnet", version, dist_tag: distTag }); + process.exit(0); + } + } finally { + rmSync(latest.dir, { recursive: true, force: true }); + } +} + +const zero = versionsOf().filter((version) => /^0\.\d+\.\d+$/.test(version)).sort(cmp); +const base = channel === "main" ? tags.latest : [tags.dev, zero.at(-1)].filter(Boolean).sort(cmp).at(-1); +const extracted = extract(base); +let raw; +let ownedChanged = false; +try { + raw = execFileSync("node", ["scripts/semver-diff.mjs", extracted.pkgRoot, root, owned], { + cwd: root, + encoding: "utf8", + }).trim(); + const published = JSON.parse(readFileSync(path.join(extracted.pkgRoot, "deployments.json"), "utf8")); + ownedChanged = + canonDeploy(published.environments?.[owned]) !== canonDeploy(current.environments?.[owned]); +} finally { + rmSync(extracted.dir, { recursive: true, force: true }); +} +console.log(`Published ${base} (${distTag} base) โ€” ABI diff requires: ${raw}`); + +let level = raw; +if (channel === "dev" && raw !== "none") level = raw === "patch" && !ownedChanged ? "patch" : "minor"; +if (channel === "dev" && !tags.dev && (level === "none" || level === "patch")) { + level = "minor"; + console.log("No dev dist-tag yet โ€” publishing a minor so testnet has its own tag"); +} + +if (level === "none") { + console.log("No ABI or owned-environment changes โ€” skipping publish"); + emit({ level: "none", version: base, dist_tag: distTag }); + process.exit(0); +} + +const version = bump(base, level); +console.log(`Version: ${version} (${level} from ${base}) on dist-tag ${distTag}`); +emit({ level, version, dist_tag: distTag }); diff --git a/collateral-abi/scripts/semver-diff.mjs b/collateral-abi/scripts/semver-diff.mjs index 8000a18..159e98d 100644 --- a/collateral-abi/scripts/semver-diff.mjs +++ b/collateral-abi/scripts/semver-diff.mjs @@ -1,23 +1,37 @@ // Computes the required semver bump by diffing the ABI surface of the // last-published package against the freshly built one. // -// Usage: node scripts/semver-diff.mjs +// Usage: node scripts/semver-diff.mjs [ownedEnv] // Prints one of: major | minor | patch | none // +// ownedEnv ("testnet" or "mainnet") limits the deployments.json +// comparison to that environment, so copying the other network forward +// does not count as a change. +// // Rules โ€” the ABI *is* the public API, so the level is computable: // - ABI entry removed or modified, or a contract file removed -> major // - New ABI entry or new contract file -> minor -// - Only metadata changed (deployments.json, README, ...) -> patch +// - Only the owned environment or README changed -> patch // - Nothing changed -> none import { existsSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; -const [publishedRoot, currentRoot] = process.argv.slice(2); +const [publishedRoot, currentRoot, ownedEnv] = process.argv.slice(2); if (!publishedRoot || !currentRoot) { - console.error("Usage: semver-diff.mjs "); + console.error("Usage: semver-diff.mjs [ownedEnv]"); process.exit(1); } +function canonDeploy(value) { + if (typeof value === "string" && /^0x[0-9a-fA-F]{40}$/.test(value)) return value.toLowerCase(); + if (Array.isArray(value)) return `[${value.map(canonDeploy).join(",")}]`; + if (value && typeof value === "object") { + const keys = Object.keys(value).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonDeploy(value[k])}`).join(",")}}`; + } + return JSON.stringify(value); +} + // Canonical stringify (sorted keys) so formatting differences don't matter function canon(value) { if (Array.isArray(value)) return `[${value.map(canon).join(",")}]`; @@ -69,12 +83,16 @@ if (removedOrChanged) { } else if (added) { console.log("minor"); } else { - // ABI surface identical โ€” check whether package metadata changed - const metaChanged = ["deployments.json", "README.md"].some((file) => { + const readmeChanged = ["README.md"].some((file) => { const oldPath = path.join(publishedRoot, file); const newPath = path.join(currentRoot, file); if (!existsSync(oldPath) || !existsSync(newPath)) return true; return readFileSync(oldPath, "utf8") !== readFileSync(newPath, "utf8"); }); - console.log(metaChanged ? "patch" : "none"); + const oldDoc = JSON.parse(readFileSync(path.join(publishedRoot, "deployments.json"), "utf8")); + const newDoc = JSON.parse(readFileSync(path.join(currentRoot, "deployments.json"), "utf8")); + const oldSlice = ownedEnv ? oldDoc.environments?.[ownedEnv] : oldDoc; + const newSlice = ownedEnv ? newDoc.environments?.[ownedEnv] : newDoc; + const deploymentsChanged = canonDeploy(oldSlice) !== canonDeploy(newSlice); + console.log(deploymentsChanged || readmeChanged ? "patch" : "none"); } diff --git a/config/dev.env b/config/dev.env index 149cd41..68fa37e 100644 --- a/config/dev.env +++ b/config/dev.env @@ -46,3 +46,5 @@ LOG_LEVEL=info # โ”€โ”€ Market-maker runtime โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ MAKER_LOG_LEVEL=debug HASHPRICE_ORACLE_SUBGRAPH_URL=https://api.goldsky.com/api/public/project_cmmz59uoa7b5201wthnkxbuqy/subgraphs/hpow-oracles/dev-latest/gn +VAULT_SUBGRAPH_URL=https://api.goldsky.com/api/public/project_cmmz59uoa7b5201wthnkxbuqy/subgraphs/collateral-vault/dev-latest/gn +POINTS_SUBGRAPH_URL=https://api.goldsky.com/api/public/project_cmmz59uoa7b5201wthnkxbuqy/subgraphs/hpow-points/dev-latest/gn diff --git a/config/prd.env b/config/prd.env index 8228df3..d1696fc 100644 --- a/config/prd.env +++ b/config/prd.env @@ -49,4 +49,6 @@ LOG_LEVEL=info # โ”€โ”€ Market-maker runtime โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ MAKER_LOG_LEVEL=info HASHPRICE_ORACLE_SUBGRAPH_URL=https://api.goldsky.com/api/public/project_cmmz5dm4l7ocp01xng61y5nwr/subgraphs/hpow-oracles/lmn-latest/gn +VAULT_SUBGRAPH_URL=https://api.goldsky.com/api/public/project_cmmz5dm4l7ocp01xng61y5nwr/subgraphs/collateral-vault/lmn-latest/gn +POINTS_SUBGRAPH_URL=https://api.goldsky.com/api/public/project_cmmz5dm4l7ocp01xng61y5nwr/subgraphs/hpow-points/lmn-latest/gn ETH_PRICE_FEED_ADDRESS=0x50015f8b17fb2C290Dde41fDc246ed0dcEE93a8b