Skip to content
Merged
198 changes: 198 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
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 but the tag
# guard, 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
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

# 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.
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
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 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: |
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}"

# 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 every check
# above bar the tag guard, and can never 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 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}"
Loading