Automatic releases with release-please

Copy Markdown View Source

Tinfoil's generated release.yml reacts to a pushed git tag by default. It can instead react to a published GitHub Release, or be invoked directly as a reusable workflow, via :trigger. To automate the "bump the version + update changelog + cut the tag" side of that loop, release-please is the standard GitHub-native choice. This guide shows how to wire it up for a tinfoil'ed Burrito app and documents the one critical gotcha most people hit.

What release-please does

Release-please watches main for conventional-commit messages (feat:, fix:, etc.), maintains a rolling "release PR" that accumulates changelog entries and a proposed version bump, and when you merge that PR, creates the git tag plus a GitHub Release object.

You still get one human gate (merging the PR). Everything else is automatic.

The gotcha: GITHUB_TOKEN suppresses downstream workflows

GitHub's Actions runtime has an anti-recursion rule: resources (tags, releases, pushes) created by a workflow that authenticated as GITHUB_TOKEN will not trigger new workflow runs.

This affects both event-based triggers, not just the default:

triggerevent release-please createsfires?
:tag_pushthe v* tagno
:release_publishedthe GitHub Releaseno

Release-please creates both with GITHUB_TOKEN unless you explicitly hand it a PAT, so with a stock setup the tag and release appear, no release.yml run ever starts, and the release simply has no binaries. Nothing errors. This is the single most common way to end up with a release and no attached artifacts.

Option 4 below sidesteps the rule entirely and is the recommended choice for a stock release-please repo. Options 1-3 make the events attributable to a user instead.

Option 1: Personal Access Token (simplest)

Create a classic PAT with repo scope (or a fine-grained PAT with contents: write, pull-requests: write, and issues: write on the repo). Add it to the repo as a secret named COMMITTER_TOKEN, then point release-please at it:

# .github/workflows/release-please.yml
name: Release Please

on:
  push:
    branches: [main]

permissions:
  contents: write
  pull-requests: write

jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - uses: googleapis/release-please-action@v4
        with:
          token: ${{ secrets.COMMITTER_TOKEN }}

The tag is now created by your PAT's identity, which counts as a distinct actor from GITHUB_TOKEN. release.yml fires normally.

This is the path tinfoil_demo uses.

Downsides: PATs expire (classic: manual renewal; fine-grained: up to a year), and they belong to a human account, so offboarding that human breaks releases.

Option 2: GitHub App (cleanest long-term)

Create a GitHub App with the same permissions, install it on the repo, and generate an installation token at workflow runtime:

jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/create-github-app-token@v1
        id: app-token
        with:
          app-id: ${{ vars.RELEASE_APP_ID }}
          private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

      - uses: googleapis/release-please-action@v4
        with:
          token: ${{ steps.app-token.outputs.token }}

Tags are now authored as <your-app>[bot]. Same "distinct actor" effect; downstream workflows fire. No per-human expiry; install the App on as many repos as you want.

Higher setup cost; requires App creation + install. Worth it once you have more than one repo using this pattern.

Option 3: Explicit workflow dispatch (no extra auth)

Leave release-please on GITHUB_TOKEN and have the release-please job explicitly dispatch release.yml after creating the release:

      - name: Trigger tinfoil release
        if: ${{ steps.release.outputs.release_created }}
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TAG: ${{ steps.release.outputs.tag_name }}
        run: gh workflow run release.yml --ref "$TAG"

This is what tinfoil itself uses internally for its dogfood smoke test.

Caveat: release.yml runs on the workflow_dispatch event, not the tag push, so the generated workflow has to accept that trigger. The default :tag_push template only accepts on: push: tags, so this option needs trigger: :workflow_call, which emits a workflow_dispatch with a tag input alongside the callable entry point.

At which point Option 4 is strictly simpler: if you are already setting that trigger, calling the workflow with uses: is one less moving part than dispatching it and waiting for a separate run.

Instead of hoping an event propagates, have the release-please workflow invoke tinfoil's workflow as a reusable one, in the same run. No event is involved, so the GITHUB_TOKEN rule never applies and no long-lived credential is needed.

tinfoil: [
  targets: [:darwin_arm64, :linux_x86_64],
  trigger: :workflow_call
]

mix tinfoil.generate then emits:

on:
  workflow_call:
    inputs:
      tag:
        description: "Tag to build and attach assets to. Defaults to the calling workflow's release event tag."
        required: false
        type: string
  workflow_dispatch:
    inputs:
      tag:
        description: "Tag to build and attach assets to, e.g. v1.2.3"
        required: true
        type: string

Add a job to release-please.yml that calls it:

  binaries:
    needs: release-please
    if: ${{ needs.release-please.outputs.release_created == 'true' }}
    uses: ./.github/workflows/release.yml
    with:
      tag: ${{ needs.release-please.outputs.tag_name }}
    secrets: inherit

The binaries job then sits alongside your other post-release jobs (Hex publish, container build, deploy) instead of living at the far end of an event that may never arrive.

Two details the generated workflow handles for you, both consequences of a workflow_call run inheriting the caller's ref:

  • GITHUB_REF_NAME is the caller's branch, usually main, not the tag. So tinfoil.build, tinfoil.publish, tinfoil.homebrew, and tinfoil.scoop are all invoked with an explicit --tag. For the build that is the difference between checking mix.exs against v1.2.3 and against main.
  • actions/checkout would otherwise take that same branch and build whatever is on it now, which is not necessarily the commit the tag points at. Every checkout pins ref: to the tag.

workflow_dispatch is emitted alongside so a failed release can be re-run from the Actions tab by typing the tag, without retagging. The tag-push trigger cannot offer that.

Publishing still runs in --attach mode, since release-please created the release earlier in the same run. See the next section.

Preserving the release-please changelog: attach mode

Release-please does not only create the tag. It creates the GitHub Release too, and writes the curated changelog section as the body.

With the default trigger: :tag_push, tinfoil's workflow fires on the tag and tries to create that same release. It already exists, so publish fails with release_already_exists_no_replace. Reaching for --replace does work, but it deletes the release and recreates it from commit-derived notes, discarding the changelog release-please just wrote.

Any trigger other than :tag_push switches the publish step to mix tinfoil.publish --attach, which looks the release up by tag and uploads only the assets. The body, prerelease flag, and draft state stay exactly as release-please wrote them.

Both non-default triggers give you that:

trigger: :workflow_call       # Option 4, recommended
trigger: :release_published   # needs Option 1, 2, or 3 for the token rule

Ordering is why attach pairs with the trigger rather than being a standalone flag. On push: tags the workflow races release-please: the tag lands first, so --attach would intermittently find no release and fail. Both other triggers run only once the release object exists, so the lookup always succeeds.

Two things to know:

  • :release_published is still subject to the token rule above. It is a workflow-triggering event like any other, so a release created by a job authenticated as GITHUB_TOKEN will not start a run. Pair it with Option 1, 2, or 3, or use :workflow_call, which has no such constraint.
  • github: [draft: true] has no effect in attach mode. Attach never edits the release, so draft state belongs to whatever created it. The generated workflow omits --draft accordingly.

If release-please is configured to create a draft release, the published event does not fire until you publish it by hand. That is a deliberate human gate, not a failure, but it does mean the binaries appear a moment after the release does. :workflow_call is not affected, since it does not wait on an event at all.

Full example

A minimal release-please.yml for an Elixir/Burrito CLI using a PAT (Option 1):

name: Release Please

on:
  push:
    branches: [main]

permissions:
  contents: write
  pull-requests: write

jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - uses: googleapis/release-please-action@v4
        with:
          token: ${{ secrets.COMMITTER_TOKEN }}
          config-file: release-please-config.json
          manifest-file: .release-please-manifest.json

release-please-config.json:

{
  "packages": {
    ".": {
      "release-type": "elixir",
      "changelog-path": "CHANGELOG.md",
      "bump-minor-pre-major": true,
      "bump-patch-for-minor-pre-major": true
    }
  },
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}

.release-please-manifest.json (initial contents; release-please updates it on each release):

{ ".": "0.1.0" }

The elixir release-type teaches release-please where to bump the version inside mix.exs.

bump-patch-for-minor-pre-major: true keeps feat: commits on a patch bump while you are still in the 0.x range, matching typical pre-1.0 hex convention.

Secrets checklist

For a fully automated flow on a tinfoil'ed Burrito app, you need these repo secrets configured:

SecretUsed byRequired?
COMMITTER_TOKEN (or App)release-please-actionYes (pick one auth option)
HEX_API_KEYmix hex.publishIf publishing a hex package
HOMEBREW_TAP_TOKEN or HOMEBREW_TAP_DEPLOY_KEYmix tinfoil.homebrewIf homebrew.enabled
SCOOP_BUCKET_TOKEN or SCOOP_BUCKET_DEPLOY_KEYmix tinfoil.scoopIf scoop.enabled

Most projects with all four surfaces enabled end up reusing one COMMITTER_TOKEN across release-please, Homebrew, and Scoop by setting token_secret: "COMMITTER_TOKEN" in both the :homebrew and :scoop blocks. That collapses the number of PATs to manage from three to one.

End-to-end flow with release-please

  1. Commit with conventional-commit messages (feat:, fix:) and push to main.
  2. release-please.yml runs and opens (or updates) a release PR titled "chore(main): release X.Y.Z".
  3. Merge the release PR.
  4. release-please creates the vX.Y.Z tag and a GitHub Release stub.
  5. The tag push fires release.yml (generated by tinfoil).
  6. Tinfoil builds every target, uploads archives + attestations, and populates the GitHub Release with binaries + checksums.
  7. If enabled, Homebrew formula and Scoop manifest are pushed to their tap/bucket repos.

Step 5 is the one that only works if release-please is authenticated as something other than GITHUB_TOKEN.