diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 7334c639..41cde868 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,9 +1,17 @@
image: $BUILD_IMAGE
stages:
+ - check
- build
- deploy
+release_check:
+ stage: check
+ script:
+ - build-aux/release-check
+ only:
+ - tags
+
build:
stage: build
script:
diff --git a/RELEASES.md b/RELEASES.md
new file mode 100644
index 00000000..58ee82c5
--- /dev/null
+++ b/RELEASES.md
@@ -0,0 +1,54 @@
+TAME Release Notes
+==================
+This file contains notes for each release of TAME since v17.4.0.
+
+TAME uses [semantic versioning]. Any major version number change represents
+backwards-incompatible changes. Each such version will be accompanied by
+notes that provide a migration path to resolve incompatibilities.
+
+TAME developers: Add new changes under a "NEXT" heading as part of the
+commits that introduce the changes. To make a new release, run
+=tools/mkrelease=, which will handle updating the heading for you.
+
+
+NEXT
+====
+This release refactors the linker, adds additional tests, and improves
+errors slightly. There are otherwise no functional changes.
+
+Compiler
+--------
+- Refactor proof-of-concept dependency graph construction code.
+- Improvements to error abstraction which will later aid in reporting.
+
+Miscellaneous
+-------------
+- `RELEASES.md` added.
+- `tools/mkrelease` added to help automate updating `RELEASES.md`.
+- `build-aux/release-check` added to check releases.
+ - This is invoked both by `tools/mkrelease` and by CI via
+ `.gitlab-ci.yml` on tags.
+
+
+v17.4.0 (2020-04-17)
+====================
+This release focuses on moving some code out of the existing XSLT-based
+compiler so that the functionality does not need to be re-implemented in
+TAMER. There are no user-facing changes aside form the introduction of two
+new templates, which are not yet expected to be used directly.
+
+=tame-core=
+-----------
+- New `rate-each` template to replace XSLT template in compiler.
+- New `yields` template to replace XSLT template in compiler.
+- Users should continue to use `rate-each` and `yields` as before rather
+ than invoking the new templates directly.
+ - The intent is to remove the `t` namespace prefix in the future so that
+ templates will be applied automatically.
+
+Compiler
+--------
+- XSLT-based compiler now emits `t:rate-each` in place of the previous XSLT
+ template.
+- XSLT-based compiler now emits `t:yields` in place of the previous XSLT
+ template.
diff --git a/build-aux/release-check b/build-aux/release-check
new file mode 100755
index 00000000..4cfdcaaf
--- /dev/null
+++ b/build-aux/release-check
@@ -0,0 +1,80 @@
+#!/bin/bash
+# Determine whether a release looks okay.
+#
+# Copyright (C) 2014-2020 Ryan Specialty Group, LLC.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+# This should be run as part of a CI system to prohibit bad tags.
+
+declare -r RELEASE_FILE="${RELEASE_FILE:-RELEASES.md}"
+
+
+tag-date()
+{
+ local -r tag="${1?Missing tag}"
+
+ git show "$tag" --date=short \
+ | awk '/^Date: / { print $2; exit }'
+}
+
+
+declare -r tag=$(git describe --abbrev=0)
+declare -r tagdate=$(tag-date "$tag")
+
+
+suggest-fix()
+{
+ echo
+ echo "Here are the commands you should use to correct this"
+ echo "bad tag:"
+ echo " \$ git tag -d $tag"
+ echo " \$ tools/mkrelease $tag"
+ echo " \$ git push -f --tags $tag"
+}
+
+
+# Check for NEXT heading first so that we can provide more clear guidance
+# for what to do.
+echo -n "checking $RELEASE_FILE for missing 'NEXT' heading... "
+! grep -q '^NEXT$' "$RELEASE_FILE" || {
+ echo "FAIL"
+ echo "error: $RELEASE_FILE contains 'NEXT' heading" >&2
+ echo
+ echo "$RELEASE_FILE must be updated to replace the 'NEXT'"
+ echo "heading with the version and date being deployed."
+ echo
+ echo "The script in tools/mkrelease will do this for you."
+
+ suggest-fix
+ exit 1
+}
+echo "OK"
+
+# A missing NEXT heading could also mean that no release notes exist at all
+# for this tag. Check.
+echo -n "checking $RELEASE_FILE for '$tag' heading... "
+grep -q "^$tag ($tagdate)\$" "$RELEASE_FILE" || {
+ echo "FAIL"
+ echo "error: $RELEASE_FILE does not contain heading for $tag" >&2
+ echo
+ echo "$RELEASE_FILE has not been updated with release notes"
+ echo "for $tag."
+ echo
+ echo "The heading should read: '$tag ($tagdate)'"
+
+ suggest-fix
+ exit 1
+}
+echo "OK"
diff --git a/tools/mkrelease b/tools/mkrelease
new file mode 100755
index 00000000..fb928ad0
--- /dev/null
+++ b/tools/mkrelease
@@ -0,0 +1,118 @@
+#!/bin/bash
+# Update release notes and tag a release
+#
+# Copyright (C) 2014-2020 Ryan Specialty Group, LLC.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+set -euo pipefail
+
+declare -r RELEASE_FILE="${RELEASE_FILE:-RELEASES.md}"
+
+
+assert-valid-tag()
+{
+ local -r tag="${1?Missing tag}"
+
+ # Note that '$' is intentionally omitted to permit suffixes
+ if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then
+ echo "error: tag '$tag' must be of the form 'vM.m.r'" >&2
+ return 1
+ fi
+
+ # Compare with the most recent tag and make sure this is greater
+ local -r prev=$(git describe --abbrev=0)
+ local -r gt=$(echo -e "$tag\n$prev" | sort -V | tail -n1)
+
+ test "$tag" == "$gt" || {
+ echo "error: tag '$tag' is not greater than previous tag '$prev'" >&2
+ return 1
+ }
+}
+
+
+extract-next()
+{
+ local -r file="${1?Missing file}"
+
+ awk '
+ /^NEXT$/ { out = 1; getline; next }
+ /^====/ && out { nextfile }
+ out { print }
+ ' "$file" \
+ | head -n-1
+}
+
+
+main()
+{
+ local tag="${1?Missing new tag name}"
+
+ assert-valid-tag "$tag" || return
+
+ # We don't want to tag anything bad!
+ make check || return
+
+ local -r notes=$(extract-next "$RELEASE_FILE")
+
+ test -n "$notes" || {
+ echo "error: missing NEXT heading in $RELEASE_FILE" >&2
+ return 1
+ }
+
+ local -r date=$(date +%Y-%m-%d)
+ local -r heading="$tag ($date)"
+ local -r hline="${heading//?/=}"
+
+ echo
+ echo "$heading"
+ echo "$hline"
+ echo "$notes"
+
+ echo
+ read -p "Accept above release notes and tag $tag? (y/N): "
+ if [[ ! "$REPLY" =~ ^y(es?)? ]]; then
+ echo "error: aborted by user" >&2
+ return 2
+ fi
+
+ # Note that this also runs the release-check after tagging to ensure that
+ # we've addressed all issues that would cause the CI job to blow up.
+ set -x
+ sed -i "/^NEXT\$/ {
+ s|^NEXT$|$heading\n$hline|
+ n;d
+ }" "$RELEASE_FILE" \
+ && git add "$RELEASE_FILE" \
+ && git commit -m "RELEASES.md: Update for $tag" \
+ && git tag "$tag" -m "$notes" \
+ && build-aux/release-check
+ set +x
+
+ echo
+ echo "Please review the above and then push your changes."
+ echo
+ echo "To reverse these actions, run:"
+ echo " \$ git reset HEAD^"
+ echo " \$ git checkout $RELEASE_FILE"
+ echo " \$ git tag -d $tag"
+}
+
+
+main "$@" || {
+ set +x
+ code=$?
+ echo "release failed" >&2
+ exit $code
+}