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
185 changes: 185 additions & 0 deletions .github/scripts/detect-config-changes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# Decides whether the devnet deploy PR may auto-merge by diffing config paths
# between the version devnet currently runs and the commit being deployed.
#
# Comparing against the DEPLOYED version (the image tags recorded in the infra
# values files), not the push range, keeps a hold sticky: a config change that
# was never shipped keeps blocking auto-merge even when later config-free
# commits deploy on top of it. Every unresolvable state fails safe to a hold:
# missing values file, per-service tag drift, non-main-<sha> tag, unknown sha.
#
# Inputs (env):
# DIR directory in the infra-kubernetes checkout (cwd) holding
# the Helm values files
# SRC path to the middleware checkout with full history
# GITHUB_SHA commit being deployed
# RUNNER_TEMP scratch directory
# GITHUB_OUTPUT step outputs file
#
# Outputs (GITHUB_OUTPUT):
# config_changed true|false — true means hold the deploy PR for manual merge
# comment_file markdown report to post on the PR (written when true)
set -euo pipefail

# Helm values files the deploy job bumps — keep in sync with FILES in
# docker-build-release.yml. Top-level key = file name minus "-values.yml".
VALUES_FILES=(
canton-middleware-api-values.yml
canton-indexer-values.yml
canton-middleware-values.yml
)

# Paths that can break a deploy when the Helm values are stale: per-package
# config structs (repo convention: yaml-tagged structs live in config.go
# files) and the default YAMLs baked into the images.
CONFIG_PATHS=(
pkg/config
'pkg/*config.go'
'cmd/*config*.go'
':!*_test.go'
':!pkg/config/tests'
)

CHANGED=false
REASON=""
DEPLOYED_TAG=""
DEPLOYED_SHA=""
COMMENT_FILE="${RUNNER_TEMP}/config-comment.md"

# Values from the infra repo are untrusted input for the markdown report.
sanitize() { printf '%s' "$1" | tr -cd 'A-Za-z0-9._-' | cut -c1-64; }

# Read the deployed tag from every service; all must agree. Per-service drift
# (e.g. a manual rollback of one service) means there is no single safe
# baseline to diff against.
TAG_REPORT=""
TAG_VALUES=()
for f in "${VALUES_FILES[@]}"; do
key="${f%-values.yml}"
tag=$(yq e ".[\"${key}\"].image.tag" "${DIR}/${f}" 2>/dev/null) || tag=""
[ "${tag}" = "null" ] && tag=""
TAG_REPORT="${TAG_REPORT}${key}: $(sanitize "${tag:-missing}"); "
TAG_VALUES+=("${tag}")
done

if printf '%s\n' "${TAG_VALUES[@]}" | grep -q '^$'; then
CHANGED=true
REASON="Could not read the deployed image tag from every values file (${TAG_REPORT}). Holding for manual review."
elif [ "$(printf '%s\n' "${TAG_VALUES[@]}" | sort -u | wc -l)" -ne 1 ]; then
CHANGED=true
REASON="Deployed image tags differ between services (${TAG_REPORT}), so there is no single safe baseline to diff against. Holding for manual review."
else
DEPLOYED_TAG="${TAG_VALUES[0]}"
fi
echo "Deployed devnet tags: ${TAG_REPORT}"

if [ -n "${DEPLOYED_TAG}" ]; then
if ! [[ "${DEPLOYED_TAG}" =~ ^main-[0-9a-f]{7,40}$ ]]; then
CHANGED=true
REASON="Deployed tag \`$(sanitize "${DEPLOYED_TAG}")\` is not a \`main-<sha>\` tag, so the config diff against the deployed version cannot be computed. Holding for manual review."
else
DEPLOYED_SHA="${DEPLOYED_TAG#main-}"
if ! git -C "${SRC}" cat-file -e "${DEPLOYED_SHA}^{commit}" 2>/dev/null; then
CHANGED=true
REASON="Deployed commit \`${DEPLOYED_SHA}\` was not found in the repository history, so the config diff cannot be computed. Holding for manual review."
DEPLOYED_SHA=""
else
CHANGED_FILES=$(git -C "${SRC}" diff --name-only "${DEPLOYED_SHA}..${GITHUB_SHA}" -- "${CONFIG_PATHS[@]}")
if [ -n "${CHANGED_FILES}" ]; then
CHANGED=true
REASON="Config files changed since deployed \`${DEPLOYED_TAG}\`."
echo "Changed config files since ${DEPLOYED_TAG}:"
echo "${CHANGED_FILES}"
fi
fi
fi
fi

# Best-effort headline for the PR comment: keys added/removed between the
# yaml:"..." struct tags of the deployed and new trees, scanned over the same
# CONFIG_PATHS the gate diffs. The raw diff in the report is the source of
# truth (key extraction misses type/default changes).
keys_with_files() {
git -C "${SRC}" grep -o -E 'yaml:"[a-zA-Z0-9_.-]+' "$1" -- "${CONFIG_PATHS[@]}" 2>/dev/null \
| sed -E 's|^[^:]+:([^:]+):yaml:"(.+)$|\2\t\1|' \
| sort -u
}

# Prints "- `key` (file)" for each key, resolving the file from the tsv map.
print_keys() {
local keys=$1 tsv=$2 k f
while IFS= read -r k; do
f=$(awk -F'\t' -v k="$k" '$1==k {print $2; exit}' "${tsv}")
echo "- \`${k}\` (${f})"
done <<< "${keys}"
}

# The heading below doubles as the comment's idempotency marker — the deploy
# step in docker-build-release.yml matches on this prefix before commenting.
write_report() {
local include_diff=$1
echo "## Config changed since deployed (\`$(sanitize "${DEPLOYED_TAG:-unknown}")\`) — auto-merge disabled"
echo
echo "${REASON}"
echo

# Without a resolvable deployed commit there is nothing to diff against.
if [ -n "${DEPLOYED_SHA}" ]; then
keys_with_files "${DEPLOYED_SHA}" > "${RUNNER_TEMP}/old_keys.tsv" || true
keys_with_files "${GITHUB_SHA}" > "${RUNNER_TEMP}/new_keys.tsv" || true
cut -f1 "${RUNNER_TEMP}/old_keys.tsv" | sort -u > "${RUNNER_TEMP}/old_keys.txt"
cut -f1 "${RUNNER_TEMP}/new_keys.tsv" | sort -u > "${RUNNER_TEMP}/new_keys.txt"
ADDED=$(comm -13 "${RUNNER_TEMP}/old_keys.txt" "${RUNNER_TEMP}/new_keys.txt")
REMOVED=$(comm -23 "${RUNNER_TEMP}/old_keys.txt" "${RUNNER_TEMP}/new_keys.txt")
if [ -n "${ADDED}" ]; then
echo "**Schema keys added** (verify the Helm values cover these):"
print_keys "${ADDED}" "${RUNNER_TEMP}/new_keys.tsv"
echo
fi
if [ -n "${REMOVED}" ]; then
echo "**Schema keys removed:**"
print_keys "${REMOVED}" "${RUNNER_TEMP}/old_keys.tsv"
echo
fi

if [ "${include_diff}" = "true" ]; then
git -C "${SRC}" diff "${DEPLOYED_SHA}..${GITHUB_SHA}" -- "${CONFIG_PATHS[@]}" > "${RUNNER_TEMP}/config.diff"
echo "<details><summary>Config diff since <code>${DEPLOYED_TAG}</code></summary>"
echo
# Four-backtick fence so diff context lines containing ``` cannot close it
echo '````diff'
head -400 "${RUNNER_TEMP}/config.diff"
if [ "$(wc -l < "${RUNNER_TEMP}/config.diff")" -gt 400 ]; then
echo "... (diff truncated at 400 lines)"
fi
echo '````'
echo
echo "</details>"
else
echo "_Raw config diff omitted (too large for a PR comment). Run locally:_"
echo '`git diff '"${DEPLOYED_SHA}..${GITHUB_SHA}"' -- pkg/config "pkg/*config.go" "cmd/*config*.go"`'
fi
echo

git -C "${SRC}" log --oneline --no-decorate "${DEPLOYED_SHA}..${GITHUB_SHA}" -- "${CONFIG_PATHS[@]}" > "${RUNNER_TEMP}/config-commits.txt"
echo "**Commits touching config:**"
head -100 "${RUNNER_TEMP}/config-commits.txt" | sed 's/^/- /'
if [ "$(wc -l < "${RUNNER_TEMP}/config-commits.txt")" -gt 100 ]; then
echo "- ... ($(wc -l < "${RUNNER_TEMP}/config-commits.txt" | tr -d ' ') commits total, truncated at 100)"
fi
echo
fi
echo "- [ ] Verify the Helm values files cover the added/changed config, then merge manually."
}

if [ "${CHANGED}" = "true" ]; then
write_report true > "${COMMENT_FILE}"
# Stay under GitHub's 65536-char comment limit: drop the inline diff if the
# full report is too large (long diff lines can exceed it despite head -400).
if [ "$(wc -c < "${COMMENT_FILE}")" -gt 60000 ]; then
write_report false > "${COMMENT_FILE}"
fi
fi

echo "config_changed=${CHANGED}" >> "${GITHUB_OUTPUT}"
echo "comment_file=${COMMENT_FILE}" >> "${GITHUB_OUTPUT}"
124 changes: 90 additions & 34 deletions .github/workflows/docker-build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,13 @@ jobs:
# Devnet continuously tracks main: deploy the main-<sha> image on every
# direct push to main. Release builds (workflow_call with a version tag)
# and manual dispatches build images only — mainnet promotion is manual.
# If config changed since the version devnet currently runs, the deploy PR
# is opened WITHOUT auto-merge and must be merged manually after checking
# the Helm values against the config diff posted as a PR comment.
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && inputs.tag == ''
env:
# Directory in infra-kubernetes holding the devnet Helm values files
DIR: definitions/canton/validator-dev1
steps:
- name: Compute image tag
id: version
Expand All @@ -144,13 +150,29 @@ jobs:
token: ${{ secrets.INFRA_GH_TOKEN }}
ref: main

# Full history is needed to diff the deployed commit against GITHUB_SHA.
- name: Checkout middleware source
uses: actions/checkout@v4
with:
path: .src
fetch-depth: 0

# The gate compares against what devnet CURRENTLY runs (the image tag
# recorded in the infra values file), not against the push range, so a
# held config change cannot slip out with a later unrelated commit.
# Unresolvable deployed tags fail safe: hold for manual review.
- name: Detect config changes since deployed
id: config
env:
SRC: .src
run: .src/.github/scripts/detect-config-changes.sh

# Each service maps to a Helm values file in infra-kubernetes whose
# top-level key matches the Helm release name. Add a service here to
# have it bumped and deployed to devnet automatically on every main push.
- name: Update image tags for all services
env:
VERSION: ${{ steps.version.outputs.version }}
DIR: definitions/canton/validator-dev1
run: |
while IFS='|' read -r key file; do
[ -z "$key" ] && continue
Expand All @@ -166,10 +188,11 @@ jobs:
env:
VERSION: ${{ steps.version.outputs.version }}
GH_TOKEN: ${{ secrets.INFRA_GH_TOKEN }}
DIR: definitions/canton/validator-dev1
REPO: ChainSafe/infra-kubernetes
SOURCE_REPO: ${{ github.repository }}
SOURCE_SHA: ${{ github.sha }}
CONFIG_CHANGED: ${{ steps.config.outputs.config_changed }}
COMMENT_FILE: ${{ steps.config.outputs.comment_file }}
run: |
FILES=(
canton-middleware-api-values.yml
Expand All @@ -191,7 +214,9 @@ jobs:
# Get current branch HEAD SHA
BRANCH_SHA=$(gh api repos/${REPO}/git/ref/heads/${BRANCH} --jq '.object.sha')

# Skip if every file on the branch already has this version (idempotent re-run)
# Commit the bumps unless the branch already has them (idempotent
# re-run). Everything after this block always runs, so a re-run
# repairs a missing PR, label, or comment instead of exiting early.
ALL_MATCH=true
for f in "${FILES[@]}"; do
KEY="${f%-values.yml}"
Expand All @@ -201,44 +226,75 @@ jobs:
[ "$BRANCH_TAG" = "$VERSION" ] || ALL_MATCH=false
done
if [ "$ALL_MATCH" = "true" ]; then
echo "Branch already at tag ${VERSION} for all services, ensuring auto-merge"
gh pr merge --auto --squash --repo "${REPO}" "${BRANCH}" 2>/dev/null || true
exit 0
fi
echo "Branch already at tag ${VERSION} for all services, skipping commit"
else
# Build the GraphQL additions array: one entry per file, base64-encoded
# (no line wrapping, Linux base64). Commits via createCommitOnBranch are
# automatically signed by GitHub (Verified).
ADDITIONS="[]"
for f in "${FILES[@]}"; do
CONTENTS=$(base64 -w0 "${DIR}/${f}")
ADDITIONS=$(jq -nc --argjson acc "$ADDITIONS" \
--arg path "${DIR}/${f}" --arg contents "$CONTENTS" \
'$acc + [{path: $path, contents: $contents}]')
done

# Build the GraphQL additions array: one entry per file, base64-encoded
# (no line wrapping, Linux base64). Commits via createCommitOnBranch are
# automatically signed by GitHub (Verified).
ADDITIONS="[]"
for f in "${FILES[@]}"; do
CONTENTS=$(base64 -w0 "${DIR}/${f}")
ADDITIONS=$(jq -nc --argjson acc "$ADDITIONS" \
--arg path "${DIR}/${f}" --arg contents "$CONTENTS" \
'$acc + [{path: $path, contents: $contents}]')
done

jq -nc \
--arg repo "$REPO" --arg branch "$BRANCH" --arg oid "$BRANCH_SHA" \
--arg msg "$COMMIT_MSG" --argjson additions "$ADDITIONS" \
'{
query: "mutation($repo: String!, $branch: String!, $oid: GitObjectID!, $msg: String!, $additions: [FileAddition!]!) { createCommitOnBranch(input: { branch: { repositoryNameWithOwner: $repo, branchName: $branch }, message: { headline: $msg }, fileChanges: { additions: $additions }, expectedHeadOid: $oid }) { commit { url } } }",
variables: { repo: $repo, branch: $branch, oid: $oid, msg: $msg, additions: $additions }
}' | gh api graphql --input -
jq -nc \
--arg repo "$REPO" --arg branch "$BRANCH" --arg oid "$BRANCH_SHA" \
--arg msg "$COMMIT_MSG" --argjson additions "$ADDITIONS" \
'{
query: "mutation($repo: String!, $branch: String!, $oid: GitObjectID!, $msg: String!, $additions: [FileAddition!]!) { createCommitOnBranch(input: { branch: { repositoryNameWithOwner: $repo, branchName: $branch }, message: { headline: $msg }, fileChanges: { additions: $additions }, expectedHeadOid: $oid }) { commit { url } } }",
variables: { repo: $repo, branch: $branch, oid: $oid, msg: $msg, additions: $additions }
}' | gh api graphql --input -
fi

# Open PR and enable auto-merge
HOLD_NOTE=""
if [ "${CONFIG_CHANGED}" = "true" ]; then
HOLD_NOTE="**Auto-merge disabled:** config changed since the deployed version. See the config diff comment and merge manually after verifying the Helm values."
fi
PR_BODY=$(cat <<EOF
Automated continuous deployment to \`validator-dev1\`.

Bumps \`canton-middleware-api\`, \`canton-indexer\` and \`canton-middleware\` image tags to \`${VERSION}\`.

Source: [${SOURCE_REPO}@${SOURCE_SHA:0:7}](https://github.com/${SOURCE_REPO}/commit/${SOURCE_SHA})
${HOLD_NOTE}
EOF
)
gh pr create \
--repo "${REPO}" \
--title "${COMMIT_MSG}" \
--body "${PR_BODY}" \
--base main \
--head "${BRANCH}" \
|| { echo "PR already exists for this branch, skipping"; exit 0; }
gh pr merge --auto --squash --repo "${REPO}" "${BRANCH}"

# Ensure the deploy PR exists. Create failures are fatal: a missing
# PR must surface as a red job, not a silently skipped deploy.
PR_NUM=$(gh pr list --repo "${REPO}" --head "${BRANCH}" --state open \
--json number --jq '.[0].number // empty')
if [ -z "${PR_NUM}" ]; then
gh pr create \
--repo "${REPO}" \
--title "${COMMIT_MSG}" \
--body "${PR_BODY}" \
--base main \
--head "${BRANCH}"
else
echo "PR #${PR_NUM} already open for ${BRANCH}"
fi

if [ "${CONFIG_CHANGED}" = "true" ]; then
echo "Config changed since deployed version — holding for manual merge"
# An earlier run may have armed auto-merge before the baseline moved
gh pr merge --disable-auto --repo "${REPO}" "${BRANCH}" 2>/dev/null || true
gh label create config-change --repo "${REPO}" \
--color D93F0B \
--description "Config changed since deployed version; manual review required" \
--force 2>/dev/null || true
gh pr edit "${BRANCH}" --repo "${REPO}" --add-label config-change || true
# Post the config report once; re-runs repair a missing comment.
# Comment failures are fatal — a held PR without the report gives
# the operator nothing to review. The startswith marker must match
# the heading written by detect-config-changes.sh.
EXISTING=$(gh pr view "${BRANCH}" --repo "${REPO}" --json comments \
--jq '[.comments[].body | select(startswith("## Config changed since deployed"))] | length')
if [ "${EXISTING}" = "0" ]; then
gh pr comment "${BRANCH}" --repo "${REPO}" --body-file "${COMMENT_FILE}"
fi
else
gh pr merge --auto --squash --repo "${REPO}" "${BRANCH}"
fi
12 changes: 12 additions & 0 deletions pkg/indexer/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ type Config struct {
UtilityRegistryHoldingPackageID string `yaml:"utility_registry_holding_package_id"`
}

// InstrumentKey is the Canton equivalent of an ERC-20 contract address.
// It uniquely identifies a CIP56 token deployment.
// Corresponds to the DAML InstrumentId{admin: Party, id: Text} record.
//
// instrumentId.id alone is NOT unique — two different issuers can both deploy
// a token with id="DEMO". The full {Admin, ID} pair IS unique and is the correct
// key for whitelisting specific token deployments.
type InstrumentKey struct {
Admin string `yaml:"admin"` // instrumentId.admin — the token admin/issuer party
ID string `yaml:"id"` // instrumentId.id — the token identifier (e.g. "DEMO")
}

// FilterModeAndKeys converts the config into the domain FilterMode and instrument
// key slice expected by engine.NewTokenTransferDecoder.
func (c *Config) FilterModeAndKeys() (FilterMode, []InstrumentKey) {
Expand Down
12 changes: 0 additions & 12 deletions pkg/indexer/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,18 +157,6 @@ type HoldingChange struct {
Locked bool
}

// InstrumentKey is the Canton equivalent of an ERC-20 contract address.
// It uniquely identifies a CIP56 token deployment.
// Corresponds to the DAML InstrumentId{admin: Party, id: Text} record.
//
// instrumentId.id alone is NOT unique — two different issuers can both deploy
// a token with id="DEMO". The full {Admin, ID} pair IS unique and is the correct
// key for whitelisting specific token deployments.
type InstrumentKey struct {
Admin string `yaml:"admin"` // instrumentId.admin — the token admin/issuer party
ID string `yaml:"id"` // instrumentId.id — the token identifier (e.g. "DEMO")
}

// Token represents a CIP56 token deployment, uniquely identified by {InstrumentAdmin, InstrumentID}.
// A Token record is created the first time the indexer observes a TokenTransferEvent for a given
// instrument pair. It tracks the ERC-20-equivalent on-chain state derivable from transfer events.
Expand Down
Loading