From 1bf9a6c6444649172f25813d14c2fc96112ef151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 22 Oct 2026 11:59:58 +0200 Subject: [PATCH 1/2] Tag and release from pushes to main The Release workflow ran when a v* tag was pushed by hand. It now runs on every push to main, like castore's publish workflow. It reads the version from src/hex_core.app.src, creates the v$VERSION tag on the pushed commit when it doesn't exist, publishes to Hex when mix hex.info doesn't find the version, and creates the GitHub release when it doesn't exist. A push that doesn't change the version skips all three, and re-running a failed run skips the steps that already succeeded. Runs share a concurrency group so two pushes can't both tag and publish the same version. The checkout keeps persist-credentials: false, so the tag is created with the GitHub API instead of git push. hex.pm matches a workload identity on the repository, the workflow file and the environment, and not on the ref, so the token exchange works from main the same way it does from a tag. --- .github/workflows/release.yml | 59 +++++++++++++++++++++++++---------- RELEASE.md | 4 +-- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1419a4c..780a080 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,12 +2,16 @@ name: Release on: push: - tags: - - "v*" + branches: + - main permissions: contents: read +# Two pushes in a row would otherwise both try to tag and publish the version +concurrency: + group: release + jobs: release: name: Release @@ -16,11 +20,12 @@ jobs: # The GitHub environment that hex_core's workload identity on hex.pm names environment: release permissions: - contents: write # create the GitHub release + contents: write # push the version tag and create the GitHub release id-token: write # the OIDC token mix hex.publish exchanges with Workload Identity steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + fetch-depth: 0 persist-credentials: false - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 @@ -34,20 +39,17 @@ jobs: - name: Install Hex run: mix archive.install github hexpm/hex ref 1d155e718aa76ab75d03f6f4bdcb0d8042f9a0ad --force - - name: Read the release notes from the changelog - env: - TAG: ${{ github.ref_name }} + - name: Read the version and its release notes from the changelog + id: version run: | version=$(erl -noshell -eval '{ok, [{application, hex_core, App}]} = file:consult("src/hex_core.app.src"), io:put_chars(proplists:get_value(vsn, App)), halt().') + tag="v$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" - if [ "$TAG" != "v$version" ]; then - echo "::error::Tag $TAG doesn't match version $version in src/hex_core.app.src" - exit 1 - fi - - # The lines under "## $TAG (date)" up to the next "## " heading, + # The lines under "## $tag (date)" up to the next "## " heading, # without leading and trailing blank lines - awk -v heading="## $TAG " ' + awk -v heading="## $tag " ' /^## / { if (found) exit found = index($0, heading) == 1 @@ -63,18 +65,41 @@ jobs: ' CHANGELOG.md > "$RUNNER_TEMP/release_notes.md" if [ ! -s "$RUNNER_TEMP/release_notes.md" ]; then - echo "::error::CHANGELOG.md has no section for $TAG" + echo "::error::CHANGELOG.md has no section for $tag" exit 1 fi + - name: Tag the version + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ steps.version.outputs.tag }} + run: | + if [ -n "$(git tag -l "$TAG")" ]; then + echo "$TAG is already tagged" + else + gh api --method POST "repos/$GH_REPO/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$GITHUB_SHA" + fi + - name: Publish to Hex env: MIX_EXS: publish.exs - run: mix hex.publish --yes + VERSION: ${{ steps.version.outputs.version }} + run: | + if mix hex.info hex_core "$VERSION"; then + echo "hex_core $VERSION is already published" + else + mix hex.publish --yes + fi - name: Create the GitHub release env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - TAG: ${{ github.ref_name }} - run: gh release create "$TAG" --title "$TAG" --verify-tag --notes-file "$RUNNER_TEMP/release_notes.md" + TAG: ${{ steps.version.outputs.tag }} + run: | + if gh release view "$TAG" > /dev/null; then + echo "The $TAG release already exists" + else + gh release create "$TAG" --title "$TAG" --verify-tag --notes-file "$RUNNER_TEMP/release_notes.md" + fi diff --git a/RELEASE.md b/RELEASE.md index bbd8c8c..4ad3156 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -8,9 +8,9 @@ 4. Commit changes above with title "Release v$VERSION" -5. Push main and the new tag `v$VERSION` +5. Push main -Pushing the tag runs the [Release workflow](.github/workflows/release.yml) in the `release` environment. It publishes the package and docs to Hex with Workload Identity and creates the GitHub release with the version's CHANGELOG section as its notes. To publish by hand instead, run `MIX_EXS=publish.exs mix hex.publish`. +Every push to main runs the [Release workflow](.github/workflows/release.yml) in the `release` environment. When the version in `src/hex_core.app.src` hasn't been released yet, it tags the pushed commit `v$VERSION`, publishes the package and docs to Hex with Workload Identity and creates the GitHub release with the version's CHANGELOG section as its notes. Each step is skipped when it's already done, so re-running a failed run picks up where it stopped. To publish by hand instead, run `MIX_EXS=publish.exs mix hex.publish`. ## Places where version is mentioned From cb4972621536c222c16d311767bd3acfbbfd33b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 22 Oct 2026 12:15:11 +0200 Subject: [PATCH 2/2] Check that every file names the same version src/hex_core.hrl and the README install snippets repeat the version from src/hex_core.app.src, and a release that bumps only some of them still publishes. .github/workflows/check_version.sh fails when the version in src/hex_core.hrl or a README snippet differs from the one in src/hex_core.app.src, or when a snippet is missing. CI runs it, and the Release workflow runs it before tagging because on a push to main it runs next to CI and doesn't wait for it. --- .github/workflows/check_version.sh | 38 ++++++++++++++++++++++++++++++ .github/workflows/main.yml | 2 ++ .github/workflows/release.yml | 3 +++ RELEASE.md | 2 ++ 4 files changed, 45 insertions(+) create mode 100755 .github/workflows/check_version.sh diff --git a/.github/workflows/check_version.sh b/.github/workflows/check_version.sh new file mode 100755 index 0000000..7bac1dd --- /dev/null +++ b/.github/workflows/check_version.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Checks that every place that names hex_core's version names the version in +# src/hex_core.app.src + +set -euo pipefail + +version=$(erl -noshell -eval '{ok, [{application, hex_core, App}]} = file:consult("src/hex_core.app.src"), io:put_chars(proplists:get_value(vsn, App)), halt().') +failed=false + +# Every line of $1 that matches the extended regex $2 must capture the version +# in the regex's first group, and at least one line must match +check() { + local file=$1 regex=$2 found + found=$(sed -nE "s/.*$regex.*/\\1/p" "$file") + + if [ -z "$found" ]; then + echo "::error file=$file::$file has no line matching $regex" + failed=true + fi + + for found_version in $found; do + if [ "$found_version" != "$version" ]; then + echo "::error file=$file::$file names version $found_version, src/hex_core.app.src has $version" + failed=true + fi + done +} + +check src/hex_core.hrl '^-define\(HEX_CORE_VERSION, "([^"]*)"\)\.$' +check README.md '\{hex_core, "([^"]*)"\}' +check README.md '\{:hex_core, "~> ([^"]*)"\}' + +if [ "$failed" = true ]; then + exit 1 +fi + +echo "Version $version matches in every file" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f9a437c..4f75c78 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -56,3 +56,5 @@ jobs: - run: rebar3 as test proper - run: rebar3 fmt --check if: ${{ matrix.pair.lint }} + - run: .github/workflows/check_version.sh + if: ${{ matrix.pair.lint }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 780a080..a58a879 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,9 @@ jobs: - name: Install Hex run: mix archive.install github hexpm/hex ref 1d155e718aa76ab75d03f6f4bdcb0d8042f9a0ad --force + - name: Check that every file names the same version + run: .github/workflows/check_version.sh + - name: Read the version and its release notes from the changelog id: version run: | diff --git a/RELEASE.md b/RELEASE.md index 4ad3156..6927cf2 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -19,3 +19,5 @@ src/hex_core.app.src src/hex_core.hrl README.md ``` + +[`check_version.sh`](.github/workflows/check_version.sh) fails when `src/hex_core.hrl` or `README.md` names a different version than `src/hex_core.app.src`. CI runs it, and the Release workflow runs it before tagging.