diff --git a/.github/actions/install-dpm/action.yml b/.github/actions/install-dpm/action.yml new file mode 100644 index 0000000..6907757 --- /dev/null +++ b/.github/actions/install-dpm/action.yml @@ -0,0 +1,75 @@ +name: install dpm +description: > + Install the pinned dpm SDK, having first asserted that every tracked daml.yaml + pins the same version. Shared by ci.yml and release.yml so the download, the + digest that verifies it and the sweep exist once rather than once per workflow. + +inputs: + sdk-version: + description: > + The dpm SDK version to install. Passed in rather than defaulted here + because ci.yml needs the same value in a step that runs before this one, + outside its scope gate and without a toolchain. + required: true + +runs: + using: composite + steps: + - shell: bash + env: + DPM_SDK_VERSION: ${{ inputs.sdk-version }} + run: | + set -euo pipefail + # Only the linux-amd64 tarball is fetched below and only its digest is + # recorded, so another architecture has nothing to install. Reported + # rather than left to a bare `test`, which exits 1 printing nothing and + # reads as the download having failed; this action is shared with the + # release workflow, where that silence would be the whole explanation + # of a failed release. + arch="$(uname -m)" + if [ "$arch" != x86_64 ]; then + echo "install-dpm supports x86_64 only, this runner is ${arch}" >&2 + exit 1 + fi + # The digest lives here and only here. A caller supplies the version it + # wants and this refuses any version it has no recorded hash for, so a + # bump cannot quietly install an unverified tarball: it has to be + # accompanied by the hash of the new one. + case "$DPM_SDK_VERSION" in + 3.4.11) tarball_sha256='177328b8d8b5781a6eee37fbd172a8445f18e07b46292c40fe32207d4cd72ccd' ;; + *) + echo "no recorded tarball digest for dpm ${DPM_SDK_VERSION}" >&2 + echo " add it to .github/actions/install-dpm/action.yml" >&2 + exit 1 + ;; + esac + # Discovered rather than listed, because a package added later carries a + # pin of its own and a list written today cannot name it. + pins="$(git ls-files '*daml.yaml')" + if [ -z "$pins" ]; then + echo "no daml.yaml is tracked, so the SDK pin went unchecked" >&2 + exit 1 + fi + while read -r manifest; do + pinned="$(awk '/^sdk-version:/ {print $2; exit}' "$manifest")" + if [ "$pinned" != "$DPM_SDK_VERSION" ]; then + echo "${manifest} pins SDK ${pinned}, this workflow installs ${DPM_SDK_VERSION}" >&2 + exit 1 + fi + echo "ok: ${manifest} pins SDK ${pinned}" + done <<<"$pins" + # The published tarball is fetched by exact version and checked against + # the recorded digest instead of piping an installer into a shell. + # Pinning is also a correctness requirement, not only a supply-chain + # one: the installer's "latest" resolves to 3.5.5, while every tracked + # daml.yaml pins 3.4.11. + tarball="dpm-${DPM_SDK_VERSION}-linux-amd64.tar.gz" + url="https://artifactregistry.googleapis.com/download/v1/projects/da-images/locations/europe/repositories/public-generic/files/dpm-sdk:${DPM_SDK_VERSION}:${tarball}:download?alt=media" + curl -SLf --retry 3 --retry-all-errors --retry-delay 5 \ + "$url" --output "${RUNNER_TEMP}/${tarball}" + echo "${tarball_sha256} ${RUNNER_TEMP}/${tarball}" | sha256sum -c - + mkdir -p "${RUNNER_TEMP}/dpm-extracted" + tar xzf "${RUNNER_TEMP}/${tarball}" -C "${RUNNER_TEMP}/dpm-extracted" --strip-components 1 + "${RUNNER_TEMP}/dpm-extracted/bin/dpm" bootstrap "${RUNNER_TEMP}/dpm-extracted" + rm -rf "${RUNNER_TEMP}/${tarball}" "${RUNNER_TEMP}/dpm-extracted" + echo "$HOME/.dpm/bin" >> "$GITHUB_PATH" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e94565f..b548b4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,7 +135,6 @@ jobs: # dies with a UTF-8 decoding error under a C or POSIX locale. LANG: C.UTF-8 DPM_SDK_VERSION: '3.4.11' - DPM_TARBALL_SHA256: '177328b8d8b5781a6eee37fbd172a8445f18e07b46292c40fe32207d4cd72ccd' steps: - uses: actions/checkout@v4 with: @@ -175,6 +174,9 @@ jobs: fi checked=( RUNBOOK.md + # Listed to keep the comparison below exhaustive. Unlike the rest, + # this file's imports are compiler-checked, by `npm run smoke`. + consumer-smoke/consumer/daml/Consumer.daml registry/.env.example registry/test/e2e/helpers/sandbox.ts registry/test/helpers/fixtures.ts @@ -291,6 +293,135 @@ jobs: done exit "$status" + # README.md's "Consuming a release" snippet is a hand transcription of + # the two blocks scripts/release-notes.sh cuts from the smoke package, + # and it is the first page a consumer reads. Nothing else compared the + # two: a SPLICE_TAG bump that ships new interface versions reds this + # job through the smoke package, the author fixes that daml.yaml, CI + # goes green, and README still tells a consumer to write the flags we + # moved off. Ungated for the same reason the step above is, and because + # README.md appears in the gate's path list nowhere at all, so a change + # to either file has to be able to red this. + - name: Verify the README consumer snippet + run: | + set -euo pipefail + smoke=consumer-smoke/consumer/daml.yaml + # The vendor path, the LF target and the --package flags: the lines + # a consumer copies. Selected by their leading '- --' or + # '- vendor/' rather than by parsing the YAML blocks, which is what + # keeps the dependencies: entries (daml-prim, daml-stdlib) out + # without a second block parser living here. + pattern='^[[:space:]]*-[[:space:]]*(--|vendor/)' + # The entries alone do not make a snippet. Nothing else here ties + # one to the block it belongs under, so README could carry every + # entry line with build-options: renamed, dropped, or the --package + # flags moved under data-dependencies:, and each comparison below + # would still match. Measured on the smoke package with the header + # renamed: dpm accepts the file, ignores the key it does not know, + # and the imports fail on "member of the hidden package", the same + # error a consumer gets with the flags missing altogether, which is + # what this whole section exists to prevent. The generated release + # body cannot drift this way: yaml_block cuts each header out of the + # smoke package itself. + for header in data-dependencies: build-options:; do + if ! grep -qxF -- "$header" README.md; then + echo "README.md does not carry the '${header}' block header" >&2 + exit 1 + fi + done + # A grep that matches nothing exits 1, which under -e kills the + # step at the assignment and leaves the emptiness report below + # unreachable. + from_smoke="$(grep -E "$pattern" "$smoke" || true)" + if [ -z "$from_smoke" ]; then + echo "${smoke} names no --package, --target or vendor entry" >&2 + exit 1 + fi + from_readme="$(grep -E "$pattern" README.md || true)" + status=0 + # Both directions. The first catches a flag README never followed + # the smoke package onto; the second catches one it kept after the + # smoke package dropped it, which is the quieter of the two because + # every line still present is individually valid. + # + # The two comparisons below, and the header loop above, take their + # pattern after '--'. The selection pattern allows no leading + # whitespace, and unindented block entries build green under dpm, so + # a manifest reformatted that way hands grep a pattern beginning + # with '-'. Without the terminator grep reads that as an option + # cluster and exits 2, which '!' turns into "README.md does not + # carry" a line it carries verbatim. + while IFS= read -r line; do + if ! grep -qxF -- "$line" README.md; then + echo "README.md does not carry '${line}' from ${smoke}" >&2 + status=1 + fi + done <<<"$from_smoke" + while IFS= read -r line; do + [ -n "$line" ] || continue + if ! grep -qxF -- "$line" "$smoke"; then + echo "README.md carries '${line}', which ${smoke} does not" >&2 + status=1 + fi + done <<<"$from_readme" + if [ "$status" = 0 ]; then + echo "ok: README.md's snippet matches ${smoke}" + fi + exit "$status" + + # DPM_SDK_VERSION is one more copy of a pin whose other homes are the + # daml.yaml files. Assert rather than trust: bumping those and forgetting + # this one otherwise fails much later, or silently makes dpm download a + # second SDK. Discovered rather than listed, because a package added + # later carries a pin of its own and a list written today cannot name it. + # Ungated for the same reason the step above is: the sweep discovers its + # manifests instead of reading the gate's list, so gating it would let a + # pull request that bumps a manifest the gate does not list pass while + # the merge to main reds. Every tracked manifest sits inside the gate + # today, but that list is written by hand and this sweep is not. It needs + # no toolchain, only the checkout, so it can run here rather than beside + # the install it guards. + # The LF target is swept here as well as in release.yml. It used to be + # asserted only there, which meant a manifest moved off 2.1 built, tested + # and merged green, and first reded on the tag push, with the tag already + # public. The two sweeps read the same manifests for the same literal; + # keeping release.yml's is what makes the one run whose output is + # permanent self-contained rather than trusting a check in another file. + - name: Verify the SDK and LF pins + run: | + set -euo pipefail + pins="$(git ls-files '*daml.yaml')" + if [ -z "$pins" ]; then + echo "no daml.yaml is tracked, so the SDK pin went unchecked" >&2 + exit 1 + fi + while read -r manifest; do + pinned="$(awk '/^sdk-version:/ {print $2; exit}' "$manifest")" + if [ "$pinned" != "$DPM_SDK_VERSION" ]; then + echo "${manifest} pins SDK ${pinned}, this workflow installs ${DPM_SDK_VERSION}" >&2 + exit 1 + fi + # Anchored to the whole entry: a substring match counts a + # commented-out line, and matches --target=2.10 as readily as 2.1. + # A grep that matches nothing exits 1, which under -e would kill + # the step here and leave the report below unreachable. + # + # Counted twice, because the anchored count says only that a live + # 2.1 entry is present, never that it is the only one. A second + # entry beside it leaves the manifest naming a target nothing + # here has inspected, whichever of the two damlc ends up using, + # and it reaches release-notes.sh as an LF row carrying two + # values. Counting every entry as well is what closes both. + entries="$(grep -cE '^[[:space:]]*-[[:space:]]*--target=' "$manifest" || true)" + target="$(grep -cE '^[[:space:]]*-[[:space:]]*--target=2\.1[[:space:]]*$' "$manifest" || true)" + if [ "$entries" != 1 ] || [ "$target" != 1 ]; then + echo "${manifest} must carry exactly one --target entry, naming LF 2.1;" \ + "found ${entries} entries, ${target} of them 2.1" >&2 + exit 1 + fi + echo "ok: ${manifest} pins SDK ${pinned} and targets LF 2.1" + done <<<"$pins" + - name: Scope id: scope env: @@ -304,9 +435,9 @@ jobs: exit 0 fi # A blip on this fetch reds the job having verified nothing, so it - # is retried rather than taken at its word. The template-id step is - # ungated so that it runs whatever the change touches; it sits above - # this one so that a red here finds it already run and reported, + # is retried rather than taken at its word. Every step above this + # one is ungated, so they run whatever the change touches; they sit + # above it so that a red here finds them already run and reported, # rather than never run at all. for attempt in 1 2 3; do git fetch --no-tags --prune origin \ @@ -318,12 +449,15 @@ jobs: changed="$(git -c core.quotePath=false diff --name-only --no-renames "origin/${BASE_REF}...HEAD")" echo "Changed files:" echo "$changed" - # Build and test inputs only. The files that merely name template - # ids are not listed: the check that reads them runs ahead of this - # gate and ignores it, so listing them here would install a - # toolchain and build both packages to verify nothing the change can - # reach. - if grep -Eq '^(daml/|scripts/fetch-dep\.sh$|versions\.env$|package\.json$|package-lock\.json$|multi-package\.yaml$|\.github/workflows/ci\.yml$)' <<<"$changed"; then + # Build and test inputs, including the smoke package the smoke + # step compiles and every script a step below runs. The + # files that merely name template ids are not listed: the check + # that reads them runs ahead of this gate and ignores it, so + # listing them here would install a toolchain and build both + # packages to verify nothing the change can reach. The install + # action is listed because this job runs it; release.yml is left + # out for that same reason, since no step in this job does. + if grep -Eq '^(daml/|consumer-smoke/|scripts/fetch-dep\.sh$|scripts/consumer-smoke\.sh$|scripts/release-notes\.sh$|\.github/actions/|versions\.env$|package\.json$|package-lock\.json$|multi-package\.yaml$|\.github/workflows/ci\.yml$)' <<<"$changed"; then echo "run=true" | tee -a "$GITHUB_OUTPUT" else echo "run=false" | tee -a "$GITHUB_OUTPUT" @@ -335,37 +469,17 @@ jobs: distribution: temurin java-version: '17' - # The published tarball is fetched by exact version and checked against a - # recorded digest instead of piping an installer into a shell. Pinning is - # also a correctness requirement, not only a supply-chain one: the - # installer's "latest" resolves to 3.5.5, while both daml.yaml files pin - # 3.4.11. + # The download, the digest that verifies it and the pin sweep live in the + # action, so release.yml runs the same three and neither workflow carries + # its own copy. It sweeps the pins again, which the ungated step above has + # already done here: that step exists to report before the toolchain and + # outside the gate, and this one is what gives release.yml the same check + # without a second copy of it. - name: Install dpm if: steps.scope.outputs.run == 'true' - run: | - set -euo pipefail - test "$(uname -m)" = "x86_64" - # DPM_SDK_VERSION is a third copy of a pin whose other two homes are - # the daml.yaml files. Assert rather than trust: bumping those and - # forgetting this one otherwise fails much later, or silently makes - # dpm download a second SDK. - for pkg in canton-token-forge canton-token-forge-test; do - pinned="$(awk '/^sdk-version:/ {print $2; exit}' "daml/${pkg}/daml.yaml")" - if [ "$pinned" != "$DPM_SDK_VERSION" ]; then - echo "daml/${pkg}/daml.yaml pins SDK ${pinned}, this workflow installs ${DPM_SDK_VERSION}" >&2 - exit 1 - fi - done - tarball="dpm-${DPM_SDK_VERSION}-linux-amd64.tar.gz" - url="https://artifactregistry.googleapis.com/download/v1/projects/da-images/locations/europe/repositories/public-generic/files/dpm-sdk:${DPM_SDK_VERSION}:${tarball}:download?alt=media" - curl -SLf --retry 3 --retry-all-errors --retry-delay 5 \ - "$url" --output "${RUNNER_TEMP}/${tarball}" - echo "${DPM_TARBALL_SHA256} ${RUNNER_TEMP}/${tarball}" | sha256sum -c - - mkdir -p "${RUNNER_TEMP}/dpm-extracted" - tar xzf "${RUNNER_TEMP}/${tarball}" -C "${RUNNER_TEMP}/dpm-extracted" --strip-components 1 - "${RUNNER_TEMP}/dpm-extracted/bin/dpm" bootstrap "${RUNNER_TEMP}/dpm-extracted" - rm -rf "${RUNNER_TEMP}/${tarball}" "${RUNNER_TEMP}/dpm-extracted" - echo "$HOME/.dpm/bin" >> "$GITHUB_PATH" + uses: ./.github/actions/install-dpm + with: + sdk-version: ${{ env.DPM_SDK_VERSION }} - name: Report the installed SDK if: steps.scope.outputs.run == 'true' @@ -453,3 +567,39 @@ jobs: - name: Test if: steps.scope.outputs.run == 'true' run: npm test + + # canton-token-forge-test lists the Splice interface DARs separately, + # so it never exercises the artifact in isolation, and a change to what + # the production package bundles or exposes is otherwise green until a + # release is cut. The Test step above just built the DAR, so a rebuild + # here would only repeat that work. release.yml sets the same variable + # for a stronger reason: the bytes it publishes must be the ones its + # reproducibility check hashed. + - name: Consumer smoke test + if: steps.scope.outputs.run == 'true' + env: + SKIP_BUILD: '1' + run: npm run smoke + + # The generator reads the smoke package and the built DAR and compares + # the two, so a change that leaves both compiling can still break the + # release body: a flag naming something the DAR stopped bundling, a + # bundled interface the snippet stopped naming, a block it can no longer + # find. Only the release workflow runs it otherwise, which would put its + # first automated run after the tag was already public. Under SKIP_BUILD + # it only reads, and it writes the body to stdout, so running it here + # costs almost nothing and leaves that body in the log to read. + # + # ALLOW_UNTAGGED because GITHUB_REF_NAME is a branch or a merge ref here, + # never a tag. It waives the refusal of a name that is not a tag, and so + # also the comparison against the checked-out commit, which only a real + # tag reaches: the body this step prints says "rebuild it at" whatever + # ref the run carries. Those two are what the step does not cover; the + # tag push + # in RUNBOOK.md's rehearsal is where it stays covered. + - name: Generate the release notes + if: steps.scope.outputs.run == 'true' + env: + SKIP_BUILD: '1' + ALLOW_UNTAGGED: '1' + run: bash scripts/release-notes.sh "${GITHUB_REF_NAME}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d03535d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,277 @@ +name: release + +# A tag push is the human gate. The name and the decision to push are the +# owner's, and a downstream repository pins the result permanently, so nothing +# here creates a tag on its own. A manual dispatch runs every step below except +# two, the on-main refusal and the publish, which are gated on a push; it also +# sets ALLOW_UNTAGGED, which waives release-notes.sh's refusal of a name that +# is not a tag in this repository, and with it the comparison against the +# checked-out commit that only a real tag ever reaches. A dispatch therefore +# emits a body naming whatever ref it was dispatched from. That is how this +# workflow is rehearsed short of publishing, and the pre-release tag push in +# RUNBOOK.md is what covers the three it leaves. +# +# The trigger requires a digit after the v. 'v*' also matches names that are +# not versions at all, such as vnext or vendor, and an unhyphenated one is +# left unmarked by the pre-release case below, so GitHub serves it from +# /releases/latest, which is the URL the README and the release body both +# send a consumer to for the DAR they pin. +on: + push: + tags: ['v[0-9]*'] + workflow_dispatch: + +# Creating a release needs write, and it is confined to this workflow. ci.yml +# stays at contents: read; this permission must not be added there. +permissions: + contents: write + +jobs: + release: + name: release + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + # damlc regenerates data-dependency interface source on every build and + # dies with a UTF-8 decoding error under a C or POSIX locale. + LANG: C.UTF-8 + DPM_SDK_VERSION: '3.4.11' + steps: + - uses: actions/checkout@v4 + with: + # The job token is write-scoped in this workflow, and checkout + # otherwise leaves it in .git/config for every step that follows, + # including the whole Daml toolchain and the Splice source it + # vendors. Nothing here needs it: the publish step is handed its own + # token, and fetch-dep.sh clones a public repository into a separate + # working copy that does not inherit this one's config. + persist-credentials: false + + # A v* tag can be pushed from any commit, including one that never + # reached main, and only a hyphenated tag is marked as a pre-release. An + # unhyphenated tag on unmerged work would become the release that + # /releases/latest serves, which a downstream repository then pins. + # release-notes.sh cannot catch this: its guard asserts the tag names the + # checked-out commit, which a tag-push checkout satisfies by definition. + # + # Asked over the API rather than with merge-base, because this repository + # is private and the checkout deliberately keeps no credentials, so a + # later git fetch has nothing to authenticate with. compare reports the + # tagged commit as identical to main or behind it exactly when main + # reaches it; ahead and diverged are the two that must not publish. + # + # First, because it needs nothing but the checkout and a token: a tag + # that can never publish should not first pay for two Splice vendorings, + # the suite and a reproducibility rebuild. Gated with the publish below + # rather than run always, + # so a dispatched rehearsal still works from a branch. The pre-release + # tag push in RUNBOOK.md is what exercises this before the real tag does. + - name: Refuse a tag that is not on main + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + status="$(gh api "repos/${GITHUB_REPOSITORY}/compare/main...${GITHUB_SHA}" --jq .status)" + case "$status" in + identical|behind) echo "ok: ${GITHUB_REF_NAME} is on main (${status})" ;; + *) + echo "${GITHUB_REF_NAME} names ${GITHUB_SHA}, which main does not reach (${status})" >&2 + exit 1 + ;; + esac + + # SPLICE_TAG is read out of versions.env rather than copied here, where + # it would have to be bumped in lockstep with that file forever; the + # value is recorded in the release body instead. The LF target is the one + # pin this file does spell out, so moving off 2.1 means editing the grep + # below as well as every manifest. The manifests are discovered rather + # than listed, so a package added later is covered without naming it here. + - name: Assert the pins agree + run: | + set -euo pipefail + splice_tag="$(awk -F= '/^SPLICE_TAG=/ {print $2; exit}' versions.env)" + if [ -z "$splice_tag" ]; then + echo "versions.env names no SPLICE_TAG" >&2 + exit 1 + fi + echo "SPLICE_TAG=${splice_tag}" + manifests="$(git ls-files '*daml.yaml')" + if [ -z "$manifests" ]; then + echo "no daml.yaml is tracked, so the LF target went unchecked" >&2 + exit 1 + fi + while read -r manifest; do + # A grep that matches nothing exits 1, and under -e that kills the + # step at this assignment before the count below is ever checked, + # so the zero case (the likely one: a manifest that never got a + # --target line, or an SDK bump that moved it) would fail with no + # diagnostic at all. Swallow the status and check the value instead. + # + # Anchored to the whole entry, because a substring match counts + # what does not pin anything: a commented-out '# - --target=2.1' + # beside a live '- --target=2.dev' reported one match and passed, + # and '--target=2.10' contains the literal too. + # + # The anchor answers only half of that. It says a live 2.1 entry + # is present once; it says nothing about a second entry beside + # it, which leaves the manifest naming a target nothing here has + # inspected. Every entry is counted as well, so the manifest has + # to carry exactly one and it has to be the pinned one. + entries="$(grep -cE '^[[:space:]]*-[[:space:]]*--target=' "$manifest" || true)" + count="$(grep -cE '^[[:space:]]*-[[:space:]]*--target=2\.1[[:space:]]*$' "$manifest" || true)" + if [ "$entries" != 1 ] || [ "$count" != 1 ]; then + echo "${manifest} must carry exactly one --target entry, naming LF 2.1;" \ + "found ${entries} entries, ${count} of them 2.1" >&2 + exit 1 + fi + echo "ok: ${manifest} targets LF 2.1" + done <<<"$manifests" + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + # Shared with ci.yml's daml job, which is what keeps the download, the + # digest that verifies it and the SDK pin sweep to one copy between the + # two workflows. The sweep is discovered rather than listed, for the same + # reason as the LF-target sweep above. + - name: Install dpm + uses: ./.github/actions/install-dpm + with: + sdk-version: ${{ env.DPM_SDK_VERSION }} + + # No cache. A tag build is rare, and a cache that can go stale is a + # liability on the one build whose output is permanent. + - name: Vendor deps + run: bash scripts/fetch-dep.sh + + - name: Test + run: npm test + + # The DAR is byte-reproducible from a clean tree today. The release body + # tells consumers they can verify the asset by rebuilding, so that + # property is checked here rather than assumed to hold forever. The + # path is read out of daml.yaml, the same idiom scripts/consumer-smoke.sh + # and scripts/release-notes.sh use, so a version bump needs no edit here. + # + # deps/ is discarded between the two builds, because a consumer verifying + # the asset starts from a clone and runs npm run setup: vendoring is one + # of the inputs their rebuild has and a second local build does not. + # npm run clean removes build output and the smoke test's copied DAR, not + # deps/, so keeping it would compare two builds over identical vendored + # source and prove only that damlc is deterministic, not the claim made. + - name: Verify the DAR is reproducible + run: | + set -euo pipefail + version="$(awk '/^version:/ {print $2; exit}' daml/canton-token-forge/daml.yaml)" + if [ -z "$version" ]; then + echo "daml/canton-token-forge/daml.yaml names no version" >&2 + exit 1 + fi + dar="daml/canton-token-forge/.daml/dist/canton-token-forge-${version}.dar" + echo "DAR=${dar}" >> "$GITHUB_ENV" + first="$(sha256sum "$dar" | cut -d' ' -f1)" + npm run clean + rm -rf deps + bash scripts/fetch-dep.sh + npm run build:canton-token-forge + second="$(sha256sum "$dar" | cut -d' ' -f1)" + if [ "$first" != "$second" ]; then + echo "DAR is not reproducible: ${first} then ${second}" >&2 + exit 1 + fi + echo "reproducible: ${first}" + + # This step and the one below both default to building the DAR again. + # Skipping that leaves the artifact the step above verified in place, so + # the bytes uploaded and the sha256 printed into the release body are the + # ones whose reproducibility was checked rather than a later build's. + - name: Consumer smoke test + env: + SKIP_BUILD: '1' + run: npm run smoke + + - name: Generate the release notes + env: + SKIP_BUILD: '1' + # A workflow_dispatch run's GITHUB_REF_NAME is whatever ref was + # picked to dispatch from and is not necessarily a tag, so + # release-notes.sh would refuse the dry run. On a tag push this + # evaluates to the empty string, which is not "1", so that refusal + # still stands where it matters. + ALLOW_UNTAGGED: ${{ github.event_name != 'push' && '1' || '' }} + run: | + set -euo pipefail + bash scripts/release-notes.sh "${GITHUB_REF_NAME}" \ + | tee "${RUNNER_TEMP}/notes.md" + + # Gated on the event, not on an input: a manual dispatch runs the checks + # above but never this step, the on-main refusal or the tag guard inside + # release-notes.sh, so it cannot publish. + - name: Publish + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # A hyphenated tag is a pre-release under semver, and the way this + # workflow gets proved is by pushing one. Unmarked, gh would make it + # the release that /releases/latest serves, which is the URL a + # downstream repository follows to find the DAR it pins. + prerelease=() + case "${GITHUB_REF_NAME}" in + *-*) prerelease=(--prerelease) ;; + esac + # Every flag precedes the positionals: gh's own examples never place + # one between two of them, so this does not lean on how its parser + # treats that. + # + # Creating and uploading are separate calls because `gh release + # create` refuses outright when the release exists. Doing both in one + # call meant that a run which created the release and then lost the + # upload left a published tag carrying no DAR, and every retry died + # on "already exists" instead of finishing the job. Creating only + # when there is nothing there makes the step re-runnable until the + # asset lands. + # + # The body is written on both passes. Leaving it alone would be + # harmless only for a release this workflow made, where a + # regenerated body is identical because it is derived from this + # same commit and tag. Nothing establishes that: a release created + # by hand to unblock a consumer after a failed run need carry none + # of the sha256, package-id or consumer snippet, and attaching the DAR + # under it would publish the asset with nothing to verify it + # against. Rewriting costs a call and makes the published body the + # generated one either way. + # + # The tag is already known to exist locally and to name this commit, + # because release-notes.sh refuses to describe anything else. + # --verify-tag asks the same of the remote, so a ref that went + # missing in between cannot end as a tag gh created itself. + # + # The edit path re-applies the pre-release marking and clears the + # draft flag, rather than passing the body alone. Both describe the + # release this workflow would have created, and neither is implied + # by the release that is already there: a hand-made one carries + # whatever its author chose, and an unmarked hyphenated tag can + # become the release /releases/latest serves, which is the failure + # the create path's marking exists to prevent. A draft is the same + # shape one step further on, leaving the tag published with nothing + # a consumer can download. + if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + echo "release ${GITHUB_REF_NAME} exists; refreshing its body and attaching the asset" + gh release edit "${GITHUB_REF_NAME}" \ + --notes-file "${RUNNER_TEMP}/notes.md" \ + --draft=false \ + "${prerelease[@]}" + else + gh release create \ + --verify-tag \ + --title "${GITHUB_REF_NAME}" \ + --notes-file "${RUNNER_TEMP}/notes.md" \ + "${prerelease[@]}" \ + "${GITHUB_REF_NAME}" + fi + gh release upload --clobber "${GITHUB_REF_NAME}" "${DAR}" diff --git a/.gitignore b/.gitignore index 829b1f8..aaef965 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ # multi-package.yaml build-harness.sh creates; never committed. deps/ +# The consumer smoke test copies the built DAR here to stand in for a +# downstream repository that received the release asset. +consumer-smoke/consumer/vendor/ + node_modules docs/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index abaa166..575780b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -68,13 +68,20 @@ registry/ Read-only TypeScript HTTP service servi async-handler.ts Wraps every route so a rejected promise reaches the error middleware openapi/ The CN Token Standard OpenAPI specs the service validates against test/ Unit suites (stub ledger) + test/e2e (live participant, skip-guarded) +consumer-smoke/ Stands in for a downstream repo that received only the release asset + multi-package.yaml Stops dpm resolving the package below against the root workspace + consumer/ + daml.yaml Data-depends on the vendored DAR alone; exposes its six bundled Splice packages + daml/Consumer.daml Converts to all seven interface instances, so dropping one breaks this build scripts/ fetch-dep.sh Vendor Splice sources, derive DAR versions, create stable-name symlinks build-harness.sh Build the Amulet test harness (unused by default; conformance only) + consumer-smoke.sh Build the DAR, then compile consumer-smoke/ against nothing else + release-notes.sh Emit the release body for a tag, snippet extracted from consumer-smoke/consumer/daml.yaml sandbox.sh Build the DAR and run a local Canton sandbox with the JSON Ledger API seed.mjs Seed a running sandbox with an admin, demo users, and one InstrumentConfig deps/ Vendored Splice sources + built DARs (gitignored; never edit or commit) -multi-package.yaml Wires the two packages into one workspace +multi-package.yaml Wires the two daml/ packages into one workspace; consumer-smoke/ has its own versions.env Single version knob: SPLICE_TAG package.json npm scripts wrapping dpm ``` @@ -361,7 +368,9 @@ overrides from `SEED_*`/`LEDGER_*` ([`RUNBOOK.md`](RUNBOOK.md)). | `npm run build:canton-token-forge` | Build only the production package. | | `npm test` | Build the production DAR, then run the `canton-token-forge-test` suite. | | `npm run test:coverage` | Same as `npm test` with a template-focused coverage report. | -| `npm run clean` | Remove both `.daml` build dirs. | +| `npm run smoke` | Compile a package that data-depends on nothing but the built DAR (`scripts/consumer-smoke.sh`); proves the release artifact is consumable on its own. | +| `bash scripts/release-notes.sh ` | Emit the release body, with the consumer snippet extracted from `consumer-smoke/consumer/daml.yaml`. Refuses if `` does not name the checked-out commit, if the working tree is dirty, or if `deps/` carries no commit stamp (`npm run setup` writes it); `ALLOW_UNTAGGED=1` previews a body before the tag exists ([`RUNBOOK.md`](RUNBOOK.md#cutting-a-release)). | +| `npm run clean` | Remove both `.daml` build dirs and the consumer smoke test's output. | | `npm run sandbox` | Build the DAR and run a local Canton sandbox with the JSON Ledger API. | | `npm run seed` | Seed a running sandbox with an admin, demo users, and one `InstrumentConfig`. | | `bash scripts/build-harness.sh` | Build the Amulet test harness (unused by default; conformance only). | diff --git a/CLAUDE.md b/CLAUDE.md index 14355ab..8c98f98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ Two packages: | Category | Technology | Notes | |----------|-----------|-------| | Language | Daml | LF target **2.1** (`build-options: --target=2.1`) | -| SDK | 3.4.11 | pinned in both `daml.yaml` files | +| SDK | 3.4.11 | pinned in all three `daml.yaml` files; CI asserts them | | Build tool | `dpm` (Digital Asset Package Manager) | NOT the legacy `daml` assistant (removed as of SDK 3.5) | | Task runner | `npm` scripts | thin wrappers over `dpm`; they set `LANG=C.UTF-8` | | Dependencies | Splice interface DARs | vendored into `deps/` by `scripts/fetch-dep.sh` (gitignored) | @@ -44,8 +44,11 @@ Two packages: assistant (removed as of SDK 3.5). Commands: `dpm build`, `dpm test`, `dpm install `. - SDK pinned to **3.4.11**, LF target **2.1** (`build-options: --target=2.1`) in - both `daml.yaml` files. Do not bump casually - the deps are built at this SDK/LF - and the test package unifies `daml-script` against it. + every tracked `daml.yaml`, of which there are three: the two packages under + `daml/` and the consumer smoke package. Do not bump casually - the deps are + built at this SDK/LF and the test package unifies `daml-script` against it. + The `daml` CI check reads the pin out of each one and fails if any disagrees + with the SDK the workflow installs. - A JDK (17+) and `dpm` must be on `PATH`. Install dpm with `curl https://get.digitalasset.com/install/install.sh | sh`, then `dpm install 3.4.11`. @@ -62,10 +65,22 @@ The **only** version you pin is the Splice release tag, in **`versions.env`** tracked Daml config**. - To move to a new Splice release: edit `SPLICE_TAG`, run `npm run setup`. -Exception: the **SDK/LF** pins live in the two `daml.yaml` files (static YAML -can't read env vars). If a new tag ships a different SDK, update `sdk-version` -(and possibly `--target`) there too. `scripts/build-harness.sh` derives the SDK -it installs from the vendored harness, so at least the install matches the tag. +Two exceptions. The **SDK/LF** pins live in the three `daml.yaml` files (static +YAML can't read env vars). If a new tag ships a different SDK, update +`sdk-version` (and possibly `--target`) there too, along with +`DPM_SDK_VERSION` in both workflows and the tarball digest in +`.github/actions/install-dpm/action.yml`, which is where the one copy of that +digest lives. Neither workflow installs an SDK a manifest disagrees with, and +the action refuses a version it holds no digest for, so a half-finished bump +reds rather than installing something unverified. +`scripts/build-harness.sh` derives the SDK it installs from the vendored +harness, so at least the install matches the tag. + +The second is `consumer-smoke/consumer/daml.yaml`, which names the six bundled +Splice interfaces as versioned unit-ids (`--package=splice-api-token-holding-v1-1.0.0` +and its five siblings). The compiler rejects the unversioned form, so there is no +stable name to point at, and a `SPLICE_TAG` that ships a different interface +version has to be followed by hand here. Nothing derives these. ## Build & test @@ -86,7 +101,8 @@ minutes. For deps only, run `bash scripts/fetch-dep.sh`. | `npm run build:canton-token-forge` | Build only the production package. | | `npm test` | Build the `canton-token-forge` DAR, then run the `canton-token-forge-test` suite. | | `npm run test:coverage` | Same, with a coverage report focused on your templates. | -| `npm run clean` | Remove both `.daml` build dirs. | +| `npm run smoke` | Build the DAR, then compile a package that data-depends on nothing but it, proving the artifact is consumable on its own. | +| `npm run clean` | Remove both `.daml` build dirs and the consumer smoke test's output. | | `npm run setup` | Re-vendor deps + re-create the stable symlinks. | | `npm run sandbox` | Build the DAR and run a local Canton sandbox with the JSON Ledger API. | | `npm run seed` | Seed a running sandbox with an admin, demo users, and one `InstrumentConfig`. | @@ -254,18 +270,40 @@ Run before declaring work done: - `npm run build` - both packages compile - `npm test` - the integration suite passes - `npm run test:coverage` - when you touched or added templates - -Every pull request has the strings that spell a template id verified against -`daml/`, whatever it touches: the `daml` check runs that comparison ahead of -its toolchain install and ignores its own scope gate. One that touches -`daml/` or the root build inputs runs the first three of these automatically -in that same check, after that step. One that touches -`registry/` runs that package's own lint, both typechecks, and unit suite as -the `registry` check, not the root commands above. `npm run test:coverage` and -the registry's `npm run test:e2e` stay manual: the first re-runs Splice's own -suites, and the second needs a live participant. The end-to-end suite is -typechecked on that path even so, which is the point of typechecking it -separately from the run. +- `npm run smoke` - when you changed what the production DAR exposes: a + renamed module or template, its dependencies, its interface instances, or its + `build-options` + +Every pull request gets three comparisons whatever it touches, because the +`daml` check runs them ahead of its toolchain install and outside its own +scope gate: the strings that spell a template id, against `daml/`; the +consumer snippet in `README.md`, against `consumer-smoke/consumer/daml.yaml`; +and every tracked `daml.yaml`, against both the SDK version the workflow +installs and LF 2.1. One that touches +`daml/`, `consumer-smoke/`, `scripts/consumer-smoke.sh` or the root build +inputs runs four of these five automatically in that same check, after those +three steps: only `npm run test:coverage` does not. One that touches `registry/` +runs that package's own lint, both typechecks, and unit suite as the +`registry` check, not the root commands above. `npm run test:coverage` and +the registry's `npm run test:e2e` are both off the pull-request path: the +first re-runs Splice's own suites, the second needs a live participant. The +`release` workflow adds checks of its own that no pull request runs. The +end-to-end suite is typechecked on that path even so, which is the point of +typechecking it separately from the run. + +A pushed tag whose `v` is followed by a digit (`v[0-9]*`, so `vnext` and +`vendor` trigger nothing) runs the `release` workflow instead: it refuses a +tag `main` does not reach, builds from a clean checkout, runs the suite, +checks the DAR is byte-reproducible, compiles `consumer-smoke/` against the +built artifact, generates the release body, and publishes the DAR as a +release asset. A manual +dispatch of that workflow runs the build and those artifact checks, cannot +publish, and leaves three things unexercised: the on-main refusal, the publish, +and `release-notes.sh`'s tag guard, which `ALLOW_UNTAGGED` waives so that a +body can be emitted for a ref that is not a tag at all. See +[`RUNBOOK.md`](RUNBOOK.md#cutting-a-release) for the procedure, including the +pre-release rehearsal, the only run that exercises that guard before the real +tag. ## References diff --git a/README.md b/README.md index 6fc43bb..fd8f601 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,45 @@ users when settling a DvP. That is the settlement venue's role rather than a token registry's, so this package declares the DAR without implementing the interface. +## Consuming a release + +Any downstream Daml package that depends on this one needs one file: +`canton-token-forge-0.0.1.dar`, attached to each +[release](https://github.com/BootNodeDev/canton-token-forge/releases). It +bundles the six `splice-api-token-*` interface packages it links against, at the +package-ids it was compiled with, so a consumer does not vendor Splice, does not +run `npm run setup`, and does not have to match `SPLICE_TAG`. + +Download it and save it as `vendor/canton-token-forge.dar`, then in your +`daml.yaml`: + +```yaml +data-dependencies: + - vendor/canton-token-forge.dar +build-options: + - --target=2.1 + - --package=splice-api-token-holding-v1-1.0.0 + - --package=splice-api-token-metadata-v1-1.0.0 + - --package=splice-api-token-transfer-instruction-v1-1.0.0 + - --package=splice-api-token-allocation-v1-1.0.0 + - --package=splice-api-token-allocation-instruction-v1-1.0.0 + - --package=splice-api-token-burn-mint-v1-1.0.0 +``` + +The `--package` lines are required: the bundled interface packages are hidden by +default, and importing `Splice.Api.Token.*` without them fails with "member of +the hidden package". You still need SDK 3.4.11 and LF 2.1 to compile against it. + +`splice-api-token-allocation-request-v1` is declared by this package but never +implemented, so it is not bundled. Take it from Splice directly if you need it. + +This snippet is transcribed from the `data-dependencies` and `build-options` +blocks in `consumer-smoke/consumer/daml.yaml`. On every pull request the `daml` +check compares both block headers and every entry under them against that file, +in both directions, so a flag, target or path that changes on one side reds +rather than ships. The release body carries the same blocks, generated straight +from that file. + ## Requirements - `dpm` (Digital Asset Package Manager) and a JDK 17+ on `PATH` @@ -118,9 +157,21 @@ declares the DAR without implementing the interface. ## Bumping Splice -Edit `SPLICE_TAG` in `versions.env`, run `npm run setup`. No other file changes - -unless the new tag ships a different SDK, in which case also update `sdk-version` -in the two `daml.yaml` files (see CLAUDE.md). +Edit `SPLICE_TAG` in `versions.env`, run `npm run setup`. Two things do not +follow from that knob (see CLAUDE.md). If the new tag ships a different SDK, +update `sdk-version` in every tracked `daml.yaml`, `DPM_SDK_VERSION` in both +workflows, and the tarball digest recorded for that version in +`.github/actions/install-dpm/action.yml`; and if it moves the LF target, +`--target` in those manifests and in the LF greps both workflows run. If it +ships different interface versions, update the `--package` unit-ids in +`consumer-smoke/consumer/daml.yaml`, which spell the version out because the +compiler rejects the unversioned form. + +Then update this file by hand for any of the three. The snippet above is +compared against the smoke package by the `daml` check, so forgetting it reds +rather than ships. The SDK and LF versions named in the prose under it are +transcriptions that nothing reads, so an edit that stops short of them leaves +the first page a consumer reads naming a version we just moved off. ## License diff --git a/RUNBOOK.md b/RUNBOOK.md index e9cdbe0..6e50f0f 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -307,3 +307,97 @@ reason the seed script looks the way it does. - **DAR not found**: run `npm run build:canton-token-forge`, or let `scripts/sandbox.sh` build it (do not pass `SKIP_BUILD=1`). - Canton logs to `log/canton.log` in the repo root, which is gitignored. + +## Cutting a release + +`.github/workflows/release.yml` builds and publishes. It runs the full suite, +checks the DAR is byte-reproducible, and compiles `consumer-smoke/` against the +built artifact before anything is published. The release body, including the +consumer snippet, is generated by `scripts/release-notes.sh` from +`consumer-smoke/consumer/daml.yaml`, so the published instructions are the ones +CI just proved compile. + +1. Rehearse: run the workflow from the Actions tab. A `workflow_dispatch` run + performs every step of it except two, and so stops short of publishing. + + Those two are the refusal of a tag `main` does not reach and the publish + itself, both gated on a tag push. A dispatch also sets `ALLOW_UNTAGGED=1`, + which waives `release-notes.sh`'s refusal of a name that is not a tag, and + with it the comparison against the checked-out commit that only a real tag + reaches, so the body it emits names the dispatched ref. To rehearse all + three, push a + hyphenated tag such as `v0.1.0-rc1` first: + the workflow marks any hyphenated tag as a pre-release, so it does not + become the release that `/releases/latest` serves. Tag a commit that is + already on `main`, for the rehearsal as much as for the real thing: the + workflow refuses to publish anything `main` does not reach, and checks that + straight after the checkout, so a tag pushed from an unmerged branch is + refused before the build starts. + + Confirm it by compiling against the asset GitHub is serving, not against a + local build of it. Nothing before this point has done that: `npm run smoke` + compiles against the DAR in `.daml/dist`, so the uploaded bytes are + unexercised until someone downloads them. Following the release body's own + instructions is the check: + + The `rm -rf` is what `scripts/consumer-smoke.sh` does before every build, + and it matters more here: if `npm run smoke` has already run, both the + vendored DAR and the build output are the local artifact, and this is the + one step meant to exercise the downloaded one. + + ```bash + gh release download v0.1.0-rc1 --pattern '*.dar' --dir /tmp/rc + shasum -a 256 /tmp/rc/canton-token-forge-0.0.1.dar # must match the body + rm -rf consumer-smoke/consumer/vendor consumer-smoke/consumer/.daml + mkdir -p consumer-smoke/consumer/vendor + cp /tmp/rc/canton-token-forge-0.0.1.dar \ + consumer-smoke/consumer/vendor/canton-token-forge.dar + ( cd consumer-smoke/consumer && LANG=C.UTF-8 dpm build ) + ``` + + Then `npm run clean`, and delete that release and its tag. +2. Tag and push. This is the decision that matters: a downstream repository pins + it permanently. + + ```bash + git tag v0.1.0 + git push origin v0.1.0 + ``` + +3. Confirm the release carries `canton-token-forge-0.0.1.dar` and that its body + shows the sha256 and package-id. The download-and-compile from step 1 is + worth repeating here, against the real tag. + + If the release appears but the DAR is missing, the upload failed after the + release was created. Re-run that failed run from its own page in the Actions + tab: it attaches the asset to the release already there rather than failing + on it. Re-running preserves the original tag push, which is what the publish + step is gated on; starting a fresh `workflow_dispatch` run instead skips + that step entirely and goes green having attached nothing. + +To run the smoke test locally without the workflow: + +```bash +npm run smoke +``` + +Two environment variables tune this path: + +- `SKIP_BUILD=1` skips the `dpm build` step in `scripts/consumer-smoke.sh` and + `scripts/release-notes.sh`, working against whatever DAR is already on disk. + The release workflow sets it on both of those steps, so the bytes it + publishes are the ones the reproducibility check just verified. + `scripts/sandbox.sh` reads the same variable, as above. +- `ALLOW_UNTAGGED=1` lets `scripts/release-notes.sh` emit a preview body before + the tag exists. The release workflow sets it only for a `workflow_dispatch` + run. Preview a body locally with: + + ```bash + ALLOW_UNTAGGED=1 bash scripts/release-notes.sh v0.1.0 + ``` + + This one refuses on a dirty working tree, untracked files included, because + the sha256 it prints has to be reproducible from the tag. A single untracked + file is enough to stop it, and the message says "dirty" rather than naming + the file. Commit first, or `git stash -u`: a plain `git stash` never takes + untracked files, so it leaves the refusal exactly where it was. diff --git a/SPEC.md b/SPEC.md index bb8134d..3eb502e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -567,14 +567,28 @@ Stated plainly, because they are what an evaluation turns on. holding a null `decimals` and the static `supportedApis`. Response validation is off, so nothing catches it on the way out; get-by-id escapes only because no id can match such a row. -- **One of the three suites above is still run by hand, and so is a further - check outside them.** A pull request that touches `daml/` runs the Daml - script suite as the `daml` check; one that touches `registry/` runs that - package's lint, its typechecks and the registry unit suite as the `registry` - check. The end-to-end suite needs a live participant, so only its types are - checked there and it is never run; off the pull-request path too is - `npm run test:coverage`, which re-runs Splice's own suites and is not one of - the three above. +- **One of the three suites above never runs on a pull request, and neither + does the coverage report nor the checks the release workflow adds of its + own.** A pull request that touches `daml/`, `consumer-smoke/`, + `scripts/consumer-smoke.sh`, or a root build input such as `versions.env`, + runs the Daml script suite as the `daml` check, and `npm run smoke` + alongside it, which compiles `consumer-smoke/` against nothing but the + built DAR. `versions.env` is in that list because a `SPLICE_TAG` bump can + ship a new interface version that compiles green while the smoke package + still names the old one, and compiling that package is what catches it. + One that touches `registry/` runs that package's lint, its typechecks and + the registry unit suite as the `registry` check. The end-to-end suite + needs a live participant, so only its types are checked there and it is + never run. Off that path too is `npm run test:coverage`, which re-runs + Splice's own suites and which nothing runs automatically. A pull request + in the `daml` check's scope compiles the smoke package, generates the + release body, which is the check that compares the published snippet + against the artifact, and asserts that every tracked manifest still pins + the SDK and targets LF 2.1. The release workflow still carries the rebuild + that proves the DAR is byte-reproducible, the refusal of a tag `main` does + not reach, and `release-notes.sh`'s tag guard, which the pull-request path + waives with `ALLOW_UNTAGGED` so that a body can be generated for a ref that + is not a tag. - **Pre-release.** Version `0.0.1`, no downstream users, no migration story, and no compatibility guarantees. @@ -600,8 +614,12 @@ registry/ Read-only HTTP service src/ Config, ledger client, mapping, disclosure, routes openapi/ The four standard specs it validates against test/ Unit suites, plus test/e2e against a live participant +consumer-smoke/ Data-depends only on the built DAR, proving the + release asset is consumable on its own scripts/ fetch-dep.sh Vendor Splice, derive versions, symlink DARs + consumer-smoke.sh Build consumer-smoke/ against the built DAR + release-notes.sh Emit the release body from the smoke package sandbox.sh Local Canton sandbox with the JSON Ledger API seed.mjs Seed an admin, demo users and one instrument versions.env The single version knob: SPLICE_TAG diff --git a/consumer-smoke/consumer/daml.yaml b/consumer-smoke/consumer/daml.yaml new file mode 100644 index 0000000..3672ff1 --- /dev/null +++ b/consumer-smoke/consumer/daml.yaml @@ -0,0 +1,30 @@ +# Stands in for a downstream repository that received only the release asset. +# The DAR is copied into vendor/ by scripts/consumer-smoke.sh rather than +# referenced in the build tree, because a consumer has no build tree of ours to +# point at. +# +# scripts/release-notes.sh publishes the data-dependencies and build-options +# blocks below VERBATIM as the instructions a downstream repository follows, so +# every line of them is downstream-facing: a path or a flag that only suits this +# package silently becomes what we tell consumers to write. +sdk-version: 3.4.11 +name: consumer-smoke +source: daml +version: 0.0.1 +dependencies: + - daml-prim + - daml-stdlib +data-dependencies: + - vendor/canton-token-forge.dar +build-options: + - --target=2.1 + # The DAR bundles these six packages but damlc hides them: importing + # Splice.Api.Token.* without these lines fails with "member of the hidden + # package". The compiler suggests ':set -package', which is a GHCi command + # and not the fix. + - --package=splice-api-token-holding-v1-1.0.0 + - --package=splice-api-token-metadata-v1-1.0.0 + - --package=splice-api-token-transfer-instruction-v1-1.0.0 + - --package=splice-api-token-allocation-v1-1.0.0 + - --package=splice-api-token-allocation-instruction-v1-1.0.0 + - --package=splice-api-token-burn-mint-v1-1.0.0 diff --git a/consumer-smoke/consumer/daml/Consumer.daml b/consumer-smoke/consumer/daml/Consumer.daml new file mode 100644 index 0000000..0a28c8f --- /dev/null +++ b/consumer-smoke/consumer/daml/Consumer.daml @@ -0,0 +1,95 @@ +-- Every import below resolves through canton-token-forge-0.0.1.dar alone. The +-- Splice interface packages are bundled inside that DAR but hidden by default; +-- the --package lines in daml.yaml are what make them visible. +module Consumer where + +import Splice.Api.Token.HoldingV1 qualified as HoldingV1 +import Splice.Api.Token.MetadataV1 (Metadata, emptyMetadata) +import Splice.Api.Token.TransferInstructionV1 qualified as TransferInstructionV1 +import Splice.Api.Token.AllocationV1 qualified as AllocationV1 +import Splice.Api.Token.AllocationInstructionV1 qualified as AllocationInstructionV1 +import Splice.Api.Token.BurnMintV1 qualified as BurnMintV1 +import Canton.TokenForge.Token (Token(..), mkToken) +import Canton.TokenForge.Registry (InstrumentConfig(..)) +import Canton.TokenForge.Locked (LockedToken) +import Canton.TokenForge.Instruction (TokenTransferInstruction) +import Canton.TokenForge.Allocation (TokenAllocation) + +ownerOf : HoldingV1.HoldingView -> Party +ownerOf v = v.owner + +adminOf : InstrumentConfig -> Party +adminOf c = c.admin + +mkOne : Party -> Party -> Text -> Token +mkOne admin owner instrumentId = mkToken admin owner instrumentId 1.0 emptyMetadata + +-- Downcasting a Splice Holding to our Token needs the interface INSTANCE the +-- DAR was compiled with, not just the interface's types. The signatures above +-- would still typecheck against types alone, so this is the line that proves +-- the instance survives into the artifact. +downcast : ContractId HoldingV1.Holding -> Update (Optional (ContractId Token, Token)) +downcast = fetchFromInterface @Token + +-- The shape cc-vesting-contracts needs: one of our templates held by reference +-- from a foreign package. +template VestingLockShape + with + admin : Party + beneficiary : Party + locked : ContractId Token + meta : Metadata + where + signatory admin, beneficiary + nonconsuming choice VestingLockShape_Peek : Optional Party + controller beneficiary + do + optToken <- downcast (toInterfaceContractId @HoldingV1.Holding locked) + pure $ fmap (\(_, t) -> t.owner) optToken + +-- A type from four of the six bundled packages, so a package dropping out of +-- the DAR is a compile error here. The conversions below already reach all +-- four, and reach them more strongly, since a type survives its `interface +-- instance` being deleted and a conversion does not. This is kept as the plain +-- statement of what the DAR has to bundle, not because it catches anything the +-- conversions would miss. +bundledPackagesReachable : + ( TransferInstructionV1.TransferInstructionResult_Output + , AllocationV1.AllocationView + , AllocationInstructionV1.AllocationInstructionResult_Output + , BurnMintV1.BurnMintFactoryView + ) -> () +bundledPackagesReachable _ = () + +-- Naming a type proves the package is bundled; it does not prove the production +-- package still IMPLEMENTS the interface, because the type outlives the +-- `interface instance` that mentions it. Every conversion below needs the +-- instance itself, so dropping an implementation breaks this package rather +-- than a downstream one. The production package declares seven and all seven +-- are converted below; Holding for Token is also reached above, by downcast. +holdingInstances : ContractId Token -> ContractId LockedToken -> [ContractId HoldingV1.Holding] +holdingInstances token locked = + [ toInterfaceContractId @HoldingV1.Holding token + , toInterfaceContractId @HoldingV1.Holding locked + ] + +factoryInstances : + InstrumentConfig + -> ( TransferInstructionV1.TransferFactory + , AllocationInstructionV1.AllocationFactory + , BurnMintV1.BurnMintFactory + ) +factoryInstances config = + ( toInterface @TransferInstructionV1.TransferFactory config + , toInterface @AllocationInstructionV1.AllocationFactory config + , toInterface @BurnMintV1.BurnMintFactory config + ) + +settlementInstances : + ContractId TokenTransferInstruction + -> ContractId TokenAllocation + -> (ContractId TransferInstructionV1.TransferInstruction, ContractId AllocationV1.Allocation) +settlementInstances instruction allocation = + ( toInterfaceContractId @TransferInstructionV1.TransferInstruction instruction + , toInterfaceContractId @AllocationV1.Allocation allocation + ) diff --git a/consumer-smoke/multi-package.yaml b/consumer-smoke/multi-package.yaml new file mode 100644 index 0000000..caa94a2 --- /dev/null +++ b/consumer-smoke/multi-package.yaml @@ -0,0 +1,4 @@ +# Stops dpm walking up to the repo-root multi-package.yaml, which does not list +# this package and fails resolution outright. +packages: + - ./consumer diff --git a/package.json b/package.json index 0f85278..c1f32ca 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "scripts": { "setup": "bash scripts/fetch-dep.sh", "postinstall": "npm run setup", - "clean": "rm -rf daml/canton-token-forge/.daml daml/canton-token-forge-test/.daml", + "clean": "rm -rf daml/canton-token-forge/.daml daml/canton-token-forge-test/.daml consumer-smoke/consumer/.daml consumer-smoke/consumer/vendor", + "smoke": "bash scripts/consumer-smoke.sh", "build": "cd daml/canton-token-forge && LANG=C.UTF-8 dpm build && cd ../canton-token-forge-test && LANG=C.UTF-8 dpm build", "build:canton-token-forge": "cd daml/canton-token-forge && LANG=C.UTF-8 dpm build", "sandbox": "bash scripts/sandbox.sh", diff --git a/scripts/consumer-smoke.sh b/scripts/consumer-smoke.sh new file mode 100755 index 0000000..2ad0e04 --- /dev/null +++ b/scripts/consumer-smoke.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# +# consumer-smoke.sh - build a package whose ONLY data-dependency is the built +# canton-token-forge DAR. +# +# This is the check that guards what a downstream repository actually does: that +# the artifact is consumable on its own, and that the exposure flags still match +# what the DAR bundles. The release workflow runs it before publishing. ci.yml +# runs it on every push to main and every merge group whatever the change +# touches, and on a pull request whose changes fall in the daml job's scope. +# +# Usage: +# bash scripts/consumer-smoke.sh # build the DAR, then compile against it +# SKIP_BUILD=1 bash scripts/consumer-smoke.sh # compile against the already-built DAR +# +# Env overrides: +# SKIP_BUILD set to 1 to skip the dpm build step +# +set -euo pipefail +export LANG=C.UTF-8 + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +DIST="daml/canton-token-forge/.daml/dist" + +# Building by default is what keeps the result honest: the step below reads +# whatever DAR is on disk, and a stale one passes while describing source that +# is no longer there. +if [ "${SKIP_BUILD:-0}" != "1" ]; then + npm run build:canton-token-forge +fi + +# Read from daml.yaml rather than hardcoded here, which would report "DAR not +# found" after a build that just succeeded, and rather than globbed: dpm leaves +# the DAR of every version ever built in dist, so a glob would keep picking an +# older one after a version bump and pass against a stale artifact. +VERSION="$(awk '/^version:/ {print $2; exit}' daml/canton-token-forge/daml.yaml)" +if [ -z "$VERSION" ]; then + echo "daml/canton-token-forge/daml.yaml names no version" >&2 + exit 1 +fi +DAR="$DIST/canton-token-forge-$VERSION.dar" +if [ ! -f "$DAR" ]; then + echo "missing $DAR - run 'npm run build:canton-token-forge' first" >&2 + exit 1 +fi + +# Copied, not referenced. Pointing at the build tree would still compile and +# would make this a weaker stand-in than the consumer it represents. The +# version-free destination name is what keeps the version out of the consumer's +# daml.yaml. +rm -rf consumer-smoke/consumer/vendor consumer-smoke/consumer/.daml +mkdir -p consumer-smoke/consumer/vendor +cp "$DAR" consumer-smoke/consumer/vendor/canton-token-forge.dar + +( cd consumer-smoke/consumer && dpm build ) +echo "consumer smoke test passed: the DAR is consumable standalone" diff --git a/scripts/fetch-dep.sh b/scripts/fetch-dep.sh index 3d3325b..3a684ab 100755 --- a/scripts/fetch-dep.sh +++ b/scripts/fetch-dep.sh @@ -40,8 +40,11 @@ sparse_clone() { } echo "Fetching daml/ + token-standard/ from canton-network/splice@${SPLICE_TAG}..." +splice_commit="" if retry sparse_clone; then - : + # Read out of the clone rather than resolved over the network: this is the + # commit whose tree is about to be vendored, which is the fact worth keeping. + splice_commit="$(git -C .tmp-splice rev-parse HEAD)" else # Fallback for environments where git's smart-HTTP pack transfer is broken # (e.g. a corrupting MITM proxy): fetch the same tag as a tarball instead. @@ -56,6 +59,29 @@ else --output .tmp-splice/source.tar.gz tar -xzf .tmp-splice/source.tar.gz -C .tmp-splice --strip-components=1 \ "splice-${SPLICE_TAG}/daml" "splice-${SPLICE_TAG}/token-standard" + # A tarball carries no commit, so this path asks the remote what the tag + # names. codeload served that same tag moments ago, so the two agree unless + # the tag moved in between, which is the case the stamp exists to expose. + # + # The status is swallowed deliberately. This path is reached because git's + # transport to that host is broken, so ls-remote can fail here for the same + # reason the clone did, and under pipefail a failing one kills the script AT + # THIS ASSIGNMENT, before anything can say which step could not resolve. + splice_commit="$(git ls-remote "$REPO" "refs/tags/${SPLICE_TAG}^{}" "refs/tags/${SPLICE_TAG}" \ + | tail -n1 | cut -f1 || true)" + # Not fatal, and that asymmetry is the point. The tarball has already been + # unpacked, so the sources this script exists to provide are in hand; only + # a release body needs the commit. Refusing here would mean the fallback + # aborts for precisely the reason it was written - a broken git transport + # to this host - and every contributor on such a network would find + # `npm install` failing where it used to work. The stamp records what is + # known, and scripts/release-notes.sh is what refuses to publish without + # the rest. + if [ -z "$splice_commit" ]; then + echo "warning: could not resolve the commit for ${SPLICE_TAG}" >&2 + echo " deps/ will still be vendored; scripts/release-notes.sh will refuse" >&2 + echo " to describe it until a run resolves the commit" >&2 + fi fi # Vendor: daml/ -> deps/splice-daml ; token-standard/ -> deps/token-standard (siblings). @@ -75,7 +101,12 @@ read_ver() { grep -m1 '^version:' "$1" | awk '{print $2}'; } # creates deps/splice-daml/dars/.dar -> -.dar link_stable() { local pkg="$1" src="$2" ver - ver="$(read_ver "$src")" + # The status is swallowed so the report below is reachable. read_ver is a + # pipeline, and under pipefail a grep that matches nothing fails the whole + # assignment, which set -e turns into an exit right here: an upstream + # release that moves or drops the version key aborted the vendor with no + # output at all, where the message below names the package and the file. + ver="$(read_ver "$src" || true)" if [ -z "$ver" ]; then echo "ERROR: could not derive version for $pkg from $src" >&2; exit 1; fi if [ ! -f "$DARS/$pkg-$ver.dar" ]; then echo "ERROR: expected DAR $DARS/$pkg-$ver.dar not found (tag $SPLICE_TAG)" >&2; exit 1 @@ -99,4 +130,33 @@ link_stable splice-api-token-allocation-request-v1 deps/token-standard/splic # the compiled DAR is still in deps/splice-daml/dars/, so the symlink resolves. link_stable splice-api-token-burn-mint-v1 deps/splice-daml/splice-api-token-burn-mint-v1/daml.yaml +# A git tag can be re-pointed upstream, so SPLICE_TAG alone does not identify +# what a build consumed. The release body tells a consumer to rebuild the DAR +# and compare hashes, which only means something if they can vendor the same +# source; this records what this run actually vendored so the body can name it. +# It lives beside the tree it describes, and is rewritten whenever that tree is. +# +# The tag is recorded next to the commit so a reader of the stamp can tell +# whether deps/ still answers to versions.env. Without it a SPLICE_TAG bump +# that was never followed by a re-vendor is invisible: the tree keeps serving +# the old source while versions.env names the new tag. +# +# Written last, once the symlinks above exist. Copying the sources and linking +# them are separate steps that fail separately: a tag that renames or drops a +# package copies fine and then dies in link_stable, and a stamp written before +# that point would name the new tag over a tree whose symlinks stop partway, +# which is the pair release-notes.sh reads as evidence deps/ can be described. Writing it +# here makes its presence mean the vendor completed rather than that the copy +# ran. +# +# An unresolved commit is written as no line at all rather than as an empty +# value or a placeholder: release-notes.sh reads this file with awk, which +# reports both of those as the empty string anyway, and a placeholder is a +# string that could reach the published Requirements table if a later reader +# forgets to test for it. Absence cannot. +{ echo "SPLICE_TAG=${SPLICE_TAG}" + if [ -n "$splice_commit" ]; then echo "SPLICE_COMMIT=${splice_commit}"; fi +} > deps/.splice-commit + +echo "Vendored canton-network/splice@${SPLICE_TAG} (${splice_commit:-commit unresolved})" echo "Done. Vendored deps + stable symlinks ready under deps/." diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100755 index 0000000..7bb309b --- /dev/null +++ b/scripts/release-notes.sh @@ -0,0 +1,336 @@ +#!/usr/bin/env bash +# +# release-notes.sh - emit the GitHub release body for a canton-token-forge DAR. +# +# The consumer snippet is EXTRACTED from consumer-smoke/consumer/daml.yaml +# rather than written here, so what gets published is what a package really +# builds with and the two cannot drift apart. Compiling that package is what +# proves the snippet, and this script does not compile it: run +# `npm run smoke` before publishing a release. +# +# Usage: +# bash scripts/release-notes.sh # build, then emit the body +# SKIP_BUILD=1 bash scripts/release-notes.sh # emit for the built DAR +# +# Env overrides: +# SKIP_BUILD set to 1 to skip the dpm build step +# ALLOW_UNTAGGED set to 1 to emit a preview body before exists +# +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +# Every field below is read out of a file, and both awk and grep report "found +# nothing" by producing no output rather than by failing. An unguarded read +# therefore publishes a release body with a blank where a version or a hash +# should be, which is worse than no release at all. +require() { + local what="$1" value="$2" + if [ -z "$value" ]; then + echo "release-notes.sh: could not read $what" >&2 + exit 1 + fi +} + +# A block whose entries are gone still yields its header line, so requiring a +# non-empty value passes and the body publishes "data-dependencies:" with +# nothing under it. The snippet is only worth publishing if it names something. +require_entries() { + local what="$1" block="$2" + require "$what" "$block" + if ! printf '%s\n' "$block" | grep -q '^[[:space:]]*-[[:space:]]'; then + echo "release-notes.sh: $what has no entries" >&2 + exit 1 + fi +} + +# One top-level block of a daml.yaml: its header line plus every line up to the +# next top-level key. Reading the two blocks BY NAME, rather than everything +# from data-dependencies to end of file, is what keeps the published snippet +# right no matter where in that file the blocks sit or what is added after +# them. The old range silently published an empty snippet when they were +# reordered, and swallowed any block appended below them. +yaml_block() { + awk -v key="$2" ' + $0 ~ "^" key ":[[:space:]]*$" { inblock = 1; print; next } + inblock && /^[A-Za-z][A-Za-z0-9_-]*:/ { inblock = 0 } + inblock { print } + ' "$1" +} + +TAG="${1:?usage: release-notes.sh }" +SMOKE="consumer-smoke/consumer/daml.yaml" + +# The body invites a consumer to rebuild the DAR at $TAG and compare hashes, +# and $TAG is otherwise just a string this script prints. Checked before the +# build because a failure here invalidates everything the build would produce. +# +# A tag that exists but names another commit is always a refusal: whichever of +# the two is wrong, the pair we would publish is. Only the not-yet-tagged case +# has an override, for previewing a body while preparing a release. +# +# Resolved under refs/tags/ rather than as a bare commit-ish, which would also +# accept a branch, HEAD or an abbreviated sha. A release body tells a consumer +# to rebuild at this name forever, so a name that moves with the next commit, +# or means nothing outside this checkout, is not one we can publish. +tagged="$(git rev-parse -q --verify "refs/tags/$TAG^{commit}" || true)" +if [ -z "$tagged" ]; then + if [ "${ALLOW_UNTAGGED:-0}" != "1" ]; then + echo "release-notes.sh: $TAG is not a tag in this repository" >&2 + echo " (set ALLOW_UNTAGGED=1 to emit a preview body)" >&2 + exit 1 + fi +elif [ "$tagged" != "$(git rev-parse HEAD)" ]; then + echo "release-notes.sh: $TAG is $tagged, not the checked-out commit" >&2 + exit 1 +fi + +# Untracked files count: an untracked .daml source under daml/ is compiled into +# the DAR and exists at no commit, which is the same irreproducible hash as an +# uncommitted edit. Build output and deps are gitignored, so neither shows here. +# +# Checked on the status rather than on the output alone: a git that cannot read +# the tree (a held index.lock, a refused ownership check) prints nothing, and +# testing only for emptiness reads that silence as "clean" and publishes the +# very hash this guard exists to withhold. +if ! tree_state="$(git status --porcelain)"; then + echo "release-notes.sh: git could not report the working tree state" >&2 + exit 1 +fi +if [ -n "$tree_state" ]; then + echo "release-notes.sh: the working tree is dirty, so the sha256 below" >&2 + echo " would not be reproducible from $TAG" >&2 + exit 1 +fi + +# Building by default is what keeps the body honest: the sha256 and package-id +# below describe whatever DAR is on disk, and a stale one publishes an artifact +# that no longer matches the source at this tag, which is exactly the +# comparison the "rebuild and compare" section invites. Build output goes to +# stderr because stdout is the release body. +if [ "${SKIP_BUILD:-0}" != "1" ]; then + npm run build:canton-token-forge >&2 +fi + +# Read from daml.yaml rather than hardcoded here, which would go stale the +# moment the package version bumps and would report the wrong DAR name in a +# release body that otherwise describes the artifact just built. +VERSION="$(awk '/^version:/ {print $2; exit}' daml/canton-token-forge/daml.yaml)" +require "the package version from daml/canton-token-forge/daml.yaml" "$VERSION" +DAR="daml/canton-token-forge/.daml/dist/canton-token-forge-$VERSION.dar" +DAR_NAME="$(basename "$DAR")" + +if [ ! -f "$DAR" ]; then + echo "missing $DAR - run 'npm run build:canton-token-forge' first" >&2 + exit 1 +fi + +# GNU coreutils on the CI runner, BSD tooling on a maintainer's macOS. Both +# print " ", so only the command name differs. +if command -v sha256sum >/dev/null 2>&1; then + sha="$(sha256sum "$DAR" | cut -d' ' -f1)" +elif command -v shasum >/dev/null 2>&1; then + sha="$(shasum -a 256 "$DAR" | cut -d' ' -f1)" +else + echo "release-notes.sh: no sha256sum or shasum on PATH" >&2 + exit 1 +fi +require "the sha256 of $DAR" "$sha" + +# Named here rather than left to fail inside the pipeline below, where the +# `|| true` swallows the status and the reader is told the package-id could +# not be read out of the DAR, which blames the artifact for a missing tool. +if ! command -v unzip >/dev/null 2>&1; then + echo "release-notes.sh: no unzip on PATH, needed to read the package-id" >&2 + exit 1 +fi + +DAR_LISTING="$(unzip -l "$DAR" || true)" +require "the contents of $DAR" "$DAR_LISTING" + +# The pipeline is allowed to fail here. A grep that matches nothing exits 1, +# and under pipefail that kills the script AT THE ASSIGNMENT, before anything +# can say what went wrong. Swallow the status and report on the value instead. +pkgid="$(printf '%s\n' "$DAR_LISTING" \ + | grep -oE "canton-token-forge-${VERSION//./\\.}-[0-9a-f]{64}" \ + | sort -u || true)" +require "the main package-id out of $DAR" "$pkgid" +# One line, not merely non-empty. sort -u collapses the repeated matches a +# single dalf produces, but two dalfs of this package at different package-ids +# survive it as two lines, and the strip below reads to the last '-' in the +# whole value, so it returns the second one and the body publishes that as +# the main package-id, with no diagnostic, in the section whose purpose is +# letting a consumer verify the asset. +if [ "$(wc -l <<< "$pkgid" | tr -d ' ')" != 1 ]; then + echo "release-notes.sh: $DAR_NAME carries more than one package-id for" \ + "canton-token-forge-$VERSION" >&2 + exit 1 +fi +# The dalf is named --, and matching that whole +# name is what picks the MAIN package out of the bundled ones. Only the +# trailing hash is the package-id itself: it is what `damlc inspect-dar --json` +# reports as main_package_id and what a template id carries, so publishing the +# prefixed form fails a consumer's literal comparison against an identical DAR. +pkgid="${pkgid##*-}" +splice_tag="$(awk -F= '/^SPLICE_TAG=/ {print $2; exit}' versions.env)" +require "SPLICE_TAG from versions.env" "$splice_tag" +# The tag names what we asked for; this names what was vendored. A tag can be +# re-pointed upstream, and then a consumer following "rebuild it and compare" +# builds against different source and gets a hash that will never match, with +# nothing in the body to tell them which of the two moved. Written by +# scripts/fetch-dep.sh beside the tree it describes, so an absent stamp means +# deps/ predates that script and the body would be describing an unknown input. +stamped_tag="$(awk -F= '/^SPLICE_TAG=/ {print $2; exit}' deps/.splice-commit 2>/dev/null || true)" +require "the vendored Splice tag from deps/.splice-commit (run 'npm run setup')" "$stamped_tag" +splice_commit="$(awk -F= '/^SPLICE_COMMIT=/ {print $2; exit}' deps/.splice-commit 2>/dev/null || true)" +require "the vendored Splice commit from deps/.splice-commit (run 'npm run setup')" "$splice_commit" +# Absence is not the only way the stamp fails to describe deps/. Editing +# SPLICE_TAG and not re-running the vendor step leaves a tree that answers to +# the old tag while versions.env, and so the row above, names the new one. The +# body would then pair a tag with a commit that tag does not name, and a +# consumer who follows the tag row vendors source this DAR was not built +# from, so the hash below is one they cannot reach. +if [ "$stamped_tag" != "$splice_tag" ]; then + echo "release-notes.sh: versions.env asks for Splice $splice_tag," \ + "but deps/ was vendored from $stamped_tag" >&2 + echo " (run 'npm run setup' to re-vendor)" >&2 + exit 1 +fi +sdk="$(awk '/^sdk-version:/ {print $2; exit}' daml/canton-token-forge/daml.yaml)" +require "the SDK pin from daml/canton-token-forge/daml.yaml" "$sdk" + +# Comments are dropped: they explain that file to us, not the artifact to a +# consumer. +deps_block="$(yaml_block "$SMOKE" data-dependencies | grep -v '^[[:space:]]*#' || true)" +require_entries "the data-dependencies block of $SMOKE" "$deps_block" +opts_block="$(yaml_block "$SMOKE" build-options | grep -v '^[[:space:]]*#' || true)" +require_entries "the build-options block of $SMOKE" "$opts_block" +snippet="$deps_block +$opts_block" + +# The snippet is only correct for the DAR it is published beside, and nothing +# else in this repo ties the two together: the forge reaches its dependencies +# through version-free symlinks, so a SPLICE_TAG bump that ships a new +# interface version builds green while these flags still name the old one. +# Compiling the smoke package is what would catch that, and this script does +# not compile, so check the names against the archive itself. Every bundled +# package is filed as /-.dalf. +packages="$(printf '%s\n' "$opts_block" \ + | sed -n 's/^[[:space:]]*-[[:space:]]*--package=//p')" +# Without these the imports fail on "member of the hidden package", so a +# snippet that has lost them is broken in the one way this package exists to +# prevent. An emptied block is caught above; this catches a block that kept +# --target and nothing else. +require "any --package flag in the build-options block of $SMOKE" "$packages" +while IFS= read -r unit; do + if ! printf '%s\n' "$DAR_LISTING" \ + | grep -qE "/${unit//./\\.}-[0-9a-f]{64}\.dalf"; then + echo "release-notes.sh: $SMOKE names --package=$unit," \ + "which $DAR_NAME does not bundle" >&2 + exit 1 + fi +done <<< "$packages" + +# The loop above is one-directional: it catches a flag naming something the DAR +# does not bundle, but not a bundled package the snippet has stopped naming. +# That is the same broken consumer build, and it is the quieter of the two, +# because every flag that remains is still valid. Derived from the archive so +# it tracks whatever interface set the DAR actually carries. +bundled="$(printf '%s\n' "$DAR_LISTING" \ + | sed -nE 's#.*/(splice-api-token-.*)-[0-9a-f]{64}\.dalf#\1#p' \ + | sort -u)" +require "the bundled splice-api-token packages of $DAR_NAME" "$bundled" +while IFS= read -r unit; do + if ! grep -qxF "$unit" <<< "$packages"; then + echo "release-notes.sh: $DAR_NAME bundles $unit, which $SMOKE" \ + "does not name with a --package flag" >&2 + exit 1 + fi +done <<< "$bundled" + +# Three values below appear in prose as well as in the snippet, and a literal +# copy of any of them goes stale silently: the count when the interface set +# changes, the LF target when an SDK bump moves it, the vendor path when the +# smoke package renames what it depends on. The last two are read from the same +# file the snippet is cut from, so the prose and the yaml cannot disagree. +# +# The count is taken from the archive rather than from the flags, because the +# prose says how many interface packages the DAR bundles and the flag list is +# not that number. Nothing deduplicates or filters it, so a repeated flag, or +# one naming a bundled package that is not an interface, inflates the count +# while both checks above still pass: each names something bundled, and every +# bundled interface is still named. $bundled is sorted, unique and already +# restricted to splice-api-token-*, so it is the set the sentence describes. +# wc, not grep -c: grep exits 1 on no match, which under set -e would kill the +# script at the assignment and leave the guard below unreachable. $bundled is +# already known non-empty here, so this only has to count it honestly. +pkg_count="$(wc -l <<< "$bundled" | tr -d ' ')" +lf_target="$(sed -n 's/^[[:space:]]*-[[:space:]]*--target=//p' <<< "$opts_block")" +require "the LF target from the build-options block of $SMOKE" "$lf_target" +# One line, not merely non-empty. The sed above collects every --target +# entry, and a second one reaches the Requirements table as a cell broken +# across two rows naming two different targets, which is neither readable nor +# true. Both workflows now refuse such a manifest; this is the copy that runs +# on a maintainer's machine, where neither has looked at it yet. +if [ "$(wc -l <<< "$lf_target" | tr -d ' ')" != 1 ]; then + echo "release-notes.sh: $SMOKE names more than one --target entry" >&2 + exit 1 +fi +vendor_path="$(sed -n 's/^[[:space:]]*-[[:space:]]*//p' <<< "$deps_block" | head -1)" +require "the data-dependency path from $SMOKE" "$vendor_path" + +# The "Not included" section below names this package as absent, which is a +# claim about the archive and not about our source. If it ever ships bundled +# the section becomes a lie that sends a consumer to Splice for something they +# already have, so it is checked rather than asserted. +ABSENT="splice-api-token-allocation-request-v1" +if grep -q "^$ABSENT-" <<< "$bundled"; then + echo "release-notes.sh: $DAR_NAME bundles $ABSENT," \ + "which this script's \"Not included\" section says it does not" >&2 + exit 1 +fi + +cat <