Skip to content

GitHub Action

Dan Riddell edited this page Oct 13, 2026 · 5 revisions

GitHub Action

danielriddell21/letsgo-action installs letsgo on a runner and runs it. It lives in its own repository, versioned separately from the binary it runs — the action is packaging rather than tooling.

name: release
on:
  push:
    tags: ["v*"]

permissions:
  contents: write      # create the release and upload assets
  id-token: write      # provenance attestation
  attestations: write
  packages: write      # only if you publish a container image

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod

      - uses: danielriddell21/letsgo-action@v1

A shallow clone is fine. letsgo falls back to the GitHub compare API when the changelog needs history the runner does not have.

Inputs

Input Default Description
version latest The letsgo release to install, a tag such as v0.8.0, or local to build ./cmd/letsgo from the checkout (for letsgo releasing itself)
command release release, plan, apply, build, verify, diff, tag, promote, yank, audit, doctor, features, or empty to install only
args "" Extra arguments, split on whitespace
working-directory . Where to run
plan-file release.plan Where plan saves the plan and apply reads it, relative to working-directory
plan-artifact release.plan The artifact name plan uploads the plan under and apply downloads it from
token github.token The forge token letsgo publishes with
tap-token "" The token a Homebrew formula is published with. Empty falls back to token
tap-app-id / tap-app-private-key "" A GitHub App that can write to the tap. Mints a token scoped to the tap alone and uses it as tap-token
tap-repository homebrew-tap The tap the App token is scoped to
attest false Attest build provenance for the dist/ archives after the command. Needs id-token: write and attestations: write

What it does besides running letsgo

  • Installs pinned plugins. Every plugin letsgo.mod pins is installed before the command runs, so a workflow never names one: the pin is the one place a plugin is chosen. The plugin store is cached between runs, keyed on letsgo.mod, at the path letsgo plugin dir reports.
  • Sets a git identity for tag and promote. Both create an annotated tag and a runner has none, so the tag is credited to letsgo-champ[bot] unless the workflow already set user.email. promote also needs the full history: check out with fetch-depth: 0.
  • Reads its outputs from letsgo. version --json and the manifest, not stdout, with a fallback for a letsgo too old to answer --json.

Outputs

Output Description
letsgo-version The letsgo version that ran
version The project version that was built or published, without a leading v
release-url The release page, when a release was published
manifest Path to the letsgo.json letsgo wrote
plan-file Path to the plan plan saved
tag The release's tag, with the module directory prefix for a nested module (services/api/v1.2.3)

Outputs come from the manifest rather than from parsing stdout — it is the file letsgo writes precisely so machines do not have to read the part meant for people.

      - uses: danielriddell21/letsgo-action@v1
        id: release
      - run: echo "published ${{ steps.release.outputs.release-url }}"

Checking a pull request

plan resolves and gates a release without building one, in about two seconds:

      - uses: danielriddell21/letsgo-action@v1
        with:
          command: plan
          args: --explain

Approving a release before it publishes

plan reads the forge and saves what a release would change; apply publishes exactly that and nothing else. Put an environment with required reviewers between them and the reviewer approves the plan itself, shown in the job summary:

on:
  push:
    tags: ["v*"]

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions: { contents: read }
    steps:
      - uses: actions/checkout@v7
        with: { fetch-depth: 0 }
      - uses: actions/setup-go@v7
        with: { go-version-file: go.mod }
      - uses: danielriddell21/letsgo-action@v1
        with: { command: plan }   # uploads release.plan, writes the job summary

  apply:
    needs: plan
    environment: release          # required reviewers approve here
    runs-on: ubuntu-latest        # a different runner from plan, on purpose
    permissions: { contents: write, id-token: write, attestations: write, packages: write }
    steps:
      - uses: actions/checkout@v7
        with: { fetch-depth: 0 }
      - uses: actions/setup-go@v7
        with: { go-version-file: go.mod }
      - uses: danielriddell21/letsgo-action@v1
        with: { command: apply }  # downloads release.plan

The plan job needs only read access, so the approval gate is also a privilege boundary. apply rebuilds the release and refuses to publish unless the rebuild is the one the plan agreed, and unless the forge is still as the plan found it. See Plan and apply for the plan format and what stales it.

Give plan or apply any args and the action runs them as written, with no plan saved, uploaded or downloaded: args: --explain is still the two-second pull request check above.

Verifying a published release

      - uses: danielriddell21/letsgo-action@v1
        with:
          command: verify
          args: v1.3.0

Releasing modules in a monorepo

A module nested in a repository releases from tags named <dir>/vX.Y.Z. Run the action from the module's directory and it scopes itself to that module — see Monorepos:

on:
  push:
    tags: ["*/v*"]

jobs:
  release:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        module: [services/api, services/worker]
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - uses: actions/setup-go@v7
        with:
          go-version-file: ${{ matrix.module }}/go.mod
      - uses: danielriddell21/letsgo-action@v1
        id: release
        # A tag names one module; every other matrix entry finds no release
        # of its own at this commit, so it fails and is skipped by the guard.
        if: startsWith(github.ref_name, matrix.module)
        with:
          working-directory: ${{ matrix.module }}
      - run: echo "released ${{ steps.release.outputs.tag }}"
        if: steps.release.outputs.tag != ''

Promoting a prerelease

Flipping an RC from pre-release to release in the GitHub UI fires release: released. This workflow turns that into a promotion: it rebuilds the RC at its own commit and publishes it as the stable release — see Prereleases:

on:
  release:
    types: [released]

jobs:
  promote:
    # released also fires for ordinary stable releases; only a prerelease tag promotes.
    if: contains(github.event.release.tag_name, '-')
    runs-on: ubuntu-latest
    permissions: { contents: write, id-token: write, attestations: write }
    steps:
      - uses: actions/checkout@v7
        with: { ref: '${{ github.event.release.tag_name }}', fetch-depth: 0 }
      - uses: actions/setup-go@v7
        with: { go-version-file: go.mod }
      - uses: danielriddell21/letsgo-action@v1
        with:
          command: promote
          args: ${{ github.event.release.tag_name }}

Editing the release with the default github.token doesn't start workflows; use an App token if the flip should trigger this one automatically. Either way a re-trigger is a no-op: promote refuses once the stable tag already exists.

Installing without running

      - uses: danielriddell21/letsgo-action@v1
        with:
          command: ""
      - run: letsgo plan && letsgo build

Pinning the version

The default is latest, because the alternative was a release of the action every time letsgo published one — and an action whose only change is a version number teaches everyone to ignore its releases.

That default is a convenience, not a guarantee. letsgo decides the archive layout and the linker flags, so it is a build input exactly as the compiler is, and a release built by a version you did not choose is a release you cannot reproduce. A repository that cares about that pins the tag:

      - uses: danielriddell21/letsgo-action@v1
        with:
          version: v0.8.0

Bump that pin deliberately. letsgo verify replays a release from the manifest, which records the version that built it, so a release stays checkable either way — pinning is what makes the next one come out the same.

How letsgo is installed

go install github.com/danielriddell21/letsgo/cmd/letsgo@<version>, or go install ./cmd/letsgo from the checkout with version: local.

The module proxy checks what it fetches against the public checksum database, which is a stronger guarantee than any digest the action could carry, and it works on every runner without a platform matrix. The cost is a short compile; letsgo has no dependencies, so it is short.

Go is a prerequisite, not something the action installs. The Go version changes the bytes of the binaries you ship, so choosing it belongs to the workflow that knows which one your project releases with. The action fails with a clear message if Go is missing.

Permissions

contents: write is the only one a plain release needs. Add id-token: write and attestations: write for build provenance, and packages: write for a container image.

A Homebrew tap lives in another repository, and the workflow token cannot write to it. That needs an App token. The action mints one scoped to the tap alone when given the App:

- uses: danielriddell21/letsgo-action@v1
  with:
    tap-app-id: ${{ vars.TAP_APP_ID }}
    tap-app-private-key: ${{ secrets.TAP_APP_PRIVATE_KEY }}

Or mint it yourself and pass it as tap-token rather than as token:

- uses: actions/create-github-app-token@v3
  id: tap-token
  with:
    app-id: ${{ vars.TAP_APP_ID }}
    private-key: ${{ secrets.TAP_APP_PRIVATE_KEY }}
    owner: ${{ github.repository_owner }}
    repositories: homebrew-tap

- uses: danielriddell21/letsgo-action@v1
  with:
    tap-token: ${{ steps.tap-token.outputs.token }}

Note what repositories does not list: the repository being released. Its own release is published with the workflow token, so the App needs no installation there — which also means a new repository cannot fail its first release for having been left out of one. Passing the App token as token instead works, and makes it a credential that can rewrite this repository's releases too. See Publishing.

Clone this wiki locally