From 5e0fe47ad18fe9eaea20d3a0c6c5e7127a7570d5 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 31 Aug 2026 13:25:40 +0000 Subject: [PATCH 1/9] ci: publish the built DAR as a release asset on tag push --- .github/workflows/release.yml | 158 ++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..12cf4be --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,158 @@ +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 check and stops +# short of publishing, which is how this workflow is rehearsed. +on: + push: + tags: ['v*'] + 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' + DPM_TARBALL_SHA256: '177328b8d8b5781a6eee37fbd172a8445f18e07b46292c40fe32207d4cd72ccd' + steps: + - uses: actions/checkout@v4 + + # Asserted against each other rather than against literals repeated here: + # a copy of SPLICE_TAG in this file would have to be bumped in lockstep + # with versions.env forever. The values themselves are recorded in the + # release body. The LF target is swept from every tracked daml.yaml + # rather than a list of paths named here, so a package added later is + # covered without editing this file. + - 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 + count="$(grep -c -- '- --target=2.1' "$manifest")" + if [ "$count" != 1 ]; then + echo "${manifest} must target LF 2.1 exactly once, found ${count}" >&2 + exit 1 + fi + echo "ok: ${manifest} targets LF 2.1" + done <<<"$manifests" + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + # Copied from ci.yml's daml job. 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 SDK pin sweep is discovered + # rather than listed, for the same reason as the LF-target sweep above. + - name: Install dpm + run: | + set -euo pipefail + test "$(uname -m)" = "x86_64" + 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" + 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" + + # 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. + - 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 + 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}" + + - name: Consumer smoke test + run: npm run smoke + + - name: Generate the release notes + env: + # A workflow_dispatch run's GITHUB_REF_NAME is the branch that was + # dispatched, not a tag, so release-notes.sh's tag-exists check + # would fail every dry run. A tag push still gets the real check: + # ALLOW_UNTAGGED stays unset on that path. + 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 every check + # above and can never publish. + - name: Publish + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release create "${GITHUB_REF_NAME}" \ + --title "${GITHUB_REF_NAME}" \ + --notes-file "${RUNNER_TEMP}/notes.md" \ + "${DAR}" From a77df8d4f70a6731b4d7a5d3cf81e6f0f7263cde Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 31 Aug 2026 13:31:03 +0000 Subject: [PATCH 2/9] fix: surface the LF-target diagnostic instead of dying at the grep --- .github/workflows/release.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12cf4be..f971a12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,12 @@ jobs: exit 1 fi while read -r manifest; do - count="$(grep -c -- '- --target=2.1' "$manifest")" + # 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. + count="$(grep -c -- '- --target=2.1' "$manifest" || true)" if [ "$count" != 1 ]; then echo "${manifest} must target LF 2.1 exactly once, found ${count}" >&2 exit 1 @@ -136,8 +141,9 @@ jobs: env: # A workflow_dispatch run's GITHUB_REF_NAME is the branch that was # dispatched, not a tag, so release-notes.sh's tag-exists check - # would fail every dry run. A tag push still gets the real check: - # ALLOW_UNTAGGED stays unset on that path. + # would fail every dry run. On a tag push this evaluates to the + # empty string, which is not "1", so the script's tag-exists check + # still fires. ALLOW_UNTAGGED: ${{ github.event_name != 'push' && '1' || '' }} run: | set -euo pipefail From 83834b789b74c2222a39dd74862b0f08d425476a Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 31 Aug 2026 13:49:04 +0000 Subject: [PATCH 3/9] ci: stop persisting the checkout credential in the release job --- .github/workflows/release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f971a12..8757610 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,14 @@ jobs: DPM_TARBALL_SHA256: '177328b8d8b5781a6eee37fbd172a8445f18e07b46292c40fe32207d4cd72ccd' 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 # Asserted against each other rather than against literals repeated here: # a copy of SPLICE_TAG in this file would have to be bumped in lockstep From a9c0fc2c4a16d6c52dc707efacfb6a12646dbb41 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 31 Aug 2026 13:50:58 +0000 Subject: [PATCH 4/9] fix: publish a pre-release tag as a pre-release --- .github/workflows/release.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8757610..caa3ed4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,7 +166,26 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - gh release create "${GITHUB_REF_NAME}" \ + # 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 asset, which is the only trailing operand: + # gh's own examples never place one between two positionals, so this + # does not lean on how its parser treats that. + # + # 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. + gh release create \ + --verify-tag \ --title "${GITHUB_REF_NAME}" \ --notes-file "${RUNNER_TEMP}/notes.md" \ + "${prerelease[@]}" \ + "${GITHUB_REF_NAME}" \ "${DAR}" From 905a0e5cdce4dd3576a9aa1c937bf90ebd1c1aeb Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 31 Aug 2026 13:49:35 +0000 Subject: [PATCH 5/9] docs: correct the manifest count in the release workflow install comment --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index caa3ed4..afc75d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,7 +79,7 @@ jobs: # 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 SDK pin sweep is discovered + # while every tracked daml.yaml pins 3.4.11. The SDK pin sweep is discovered # rather than listed, for the same reason as the LF-target sweep above. - name: Install dpm run: | From 32152726f2b70ca5fe774f17dacb327ac6a9e64e Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 31 Aug 2026 13:49:57 +0000 Subject: [PATCH 6/9] docs: correct what a dispatched run's ref name can be --- .github/workflows/release.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index afc75d0..d483641 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,11 +147,11 @@ jobs: - name: Generate the release notes env: - # A workflow_dispatch run's GITHUB_REF_NAME is the branch that was - # dispatched, not a tag, so release-notes.sh's tag-exists check - # would fail every dry run. On a tag push this evaluates to the - # empty string, which is not "1", so the script's tag-exists check - # still fires. + # 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 From 856f052d96b94a3915fbd7a96c183cb1d16b4a91 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 31 Aug 2026 14:31:24 +0000 Subject: [PATCH 7/9] fix: publish the DAR the job verified rather than a later rebuild --- .github/workflows/release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d483641..d2d29f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,11 +142,18 @@ jobs: 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 From 7a4e4ae10773f71fb972663ccd391b826acc1a09 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 31 Aug 2026 14:32:12 +0000 Subject: [PATCH 8/9] docs: say plainly that this file spells out the LF target --- .github/workflows/release.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2d29f8..241d1bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,12 +36,12 @@ jobs: # working copy that does not inherit this one's config. persist-credentials: false - # Asserted against each other rather than against literals repeated here: - # a copy of SPLICE_TAG in this file would have to be bumped in lockstep - # with versions.env forever. The values themselves are recorded in the - # release body. The LF target is swept from every tracked daml.yaml - # rather than a list of paths named here, so a package added later is - # covered without editing this file. + # 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 From b7ab84eec8982227142399b0579dc8ebcf424aec Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 31 Aug 2026 14:32:42 +0000 Subject: [PATCH 9/9] docs: name the one check a dispatched run does not make --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 241d1bc..8bf39d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,8 @@ 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 check and stops -# short of publishing, which is how this workflow is rehearsed. +# here creates a tag on its own. A manual dispatch runs every check but the tag +# guard, and stops short of publishing, which is how this workflow is rehearsed. on: push: tags: ['v*'] @@ -166,7 +166,7 @@ jobs: | tee "${RUNNER_TEMP}/notes.md" # Gated on the event, not on an input: a manual dispatch runs every check - # above and can never publish. + # above bar the tag guard, and can never publish. - name: Publish if: github.event_name == 'push' env: