Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ jobs:
run: python3 -m unittest discover -s dependabot/tests -v
- name: Bot policy tests
run: python3 -m unittest discover -s bot/tests -v
- name: Go release workflow tests
run: python3 -m unittest discover -s go-release/tests -v

actionlint:
name: actionlint
Expand Down
213 changes: 213 additions & 0 deletions .github/workflows/go-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# Reusable Go release: build the binaries and publish them on the repository's GitHub release,
# and nowhere else (no Go module proxy path, Homebrew, Scoop, Winget, image or any other channel).
# The standard is ~/.claude/rules/go.md, "Go releases"; this file is its one implementation.
#
# A caller's release.yml declares the triggers (a pushed vX.Y.Z tag, or a dispatch naming one) and
# grants the permissions; templates/go/release.yml is the caller to copy. From the caller:
#
# jobs:
# release:
# permissions: { contents: write, id-token: write, attestations: write }
# uses: simtabi/.github/.github/workflows/go-release.yml@<commit>
# with:
# tag: ${{ inputs.tag || github.ref_name }}
#
# Steps: resolve and check the tag; refuse a GoReleaser config that publishes anywhere else; take
# the release notes from the tag's CHANGELOG section (fail closed); push the module's subdirectory
# alias tag (src/vX.Y.Z); run GoReleaser; read the published notes back; run the caller's optional
# post-release command (extra assets); attest build provenance for everything checksummed.
name: Go release

on:
workflow_call:
inputs:
tag:
description: 'The vX.Y.Z (or vX.Y.Z-pre) tag to build and publish; it must already be on GitHub'
required: true
type: string
goreleaser-config:
description: 'GoReleaser config, relative to the repository root'
required: false
type: string
default: src/.goreleaser.yaml
go-version-file:
description: 'go.mod that pins the Go version'
required: false
type: string
default: src/go.mod
module-alias-prefix:
description: "Subdirectory the Go module lives in, for its alias tag (src/ gives src/vX.Y.Z); empty for none"
required: false
type: string
default: src/
changelog:
description: 'CHANGELOG whose "## [X.Y.Z]" section becomes the release notes'
required: false
type: string
default: CHANGELOG.md
post-release:
description: >-
Optional command run from the repository root after GoReleaser, with TAG, VERSION, DIST and
GH_TOKEN set, to build and attach extra assets. It may write $RUNNER_TEMP/extra-checksums.txt
(sha256sum format, bare names) to have those assets attested too.
required: false
type: string
default: ''

permissions: {}

jobs:
release:
name: Publish release
runs-on: ubuntu-latest
timeout-minutes: 40
permissions:
contents: write # create the release, upload assets, push the module alias tag
id-token: write # Sigstore keyless identity, for cosign and the attestation
attestations: write # store the build-provenance attestation
env:
TAG: ${{ inputs.tag }}
# Our own modules come from git, never through the public proxy, so a release build never
# feeds proxy.golang.org or sum.golang.org a tag.
GOPRIVATE: github.com/${{ github.repository_owner }}/*
GONOSUMDB: github.com/${{ github.repository_owner }}/*
steps:
- name: Check the tag
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::'$TAG' is not a vX.Y.Z or vX.Y.Z-pre tag."
exit 1
fi
if ! gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent; then
echo "::error::$TAG is not a tag on $GITHUB_REPOSITORY. Push it first; a release builds from a tag only."
exit 1
fi
echo "VERSION=${TAG#v}" >> "$GITHUB_ENV"

- name: Checkout the tag
uses: actions/checkout@v7
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0 # GoReleaser reads the tag and the history

# The standard, enforced where every release passes: the GitHub release is the only channel.
- name: Refuse any other publishing channel
env:
CONFIG: ${{ inputs.goreleaser-config }}
run: |
set -euo pipefail
[ -f "$CONFIG" ] || { echo "::error::no GoReleaser config at $CONFIG"; exit 1; }
banned='brews|homebrew_casks|scoops|winget|chocolateys|aurs|aur_sources|snapcrafts|flatpaks|nix|dockers|dockers_v2|docker_manifests|docker_signs|kos|krews|npms|cloudsmiths|furies|publishers|blobs|uploads|artifactories'
if grep -nE "^($banned):" "$CONFIG"; then
echo "::error::$CONFIG publishes outside the GitHub release (the blocks above). Remove them: binaries ship on the GitHub release only."
exit 1
fi
dist=$(sed -nE 's/^dist:[[:space:]]*"?([^"#[:space:]]+)"?.*/\1/p' "$CONFIG")
echo "DIST=${dist:-dist}" >> "$GITHUB_ENV"

- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: ${{ inputs.go-version-file }}
cache-dependency-path: ${{ inputs.module-alias-prefix }}go.sum

# syft generates GoReleaser's per-archive SBOMs; cosign signs checksums.txt where the config asks.
- name: Install syft
uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3

- name: Install cosign
uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3

# Fail closed: a final tag whose version has no CHANGELOG section is a tag on the wrong commit
# or a release nobody wrote notes for. A pre-release tag is a test build and needs none.
# Written outside the checkout: GoReleaser refuses to release from a dirty tree.
- name: Extract the changelog section
env:
CHANGELOG: ${{ inputs.changelog }}
run: |
set -euo pipefail
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { found = 1; next }
found && /^## \[/ { exit }
found { print }
' "$CHANGELOG" > "$RUNNER_TEMP/section.md"
if [ -z "$(tr -d '[:space:]' < "$RUNNER_TEMP/section.md")" ] && [[ "$VERSION" == *-* ]]; then
echo "Pre-release build ${TAG}, for testing. The notes ship with the final v${VERSION%%-*}." > "$RUNNER_TEMP/section.md"
fi
if [ -z "$(tr -d '[:space:]' < "$RUNNER_TEMP/section.md")" ]; then
echo "::error::$CHANGELOG has no '## [$VERSION]' section. Refusing to publish $TAG."
exit 1
fi
{
cat "$RUNNER_TEMP/section.md"
echo ''
echo "**Full changelog:** https://github.com/${GITHUB_REPOSITORY}/blob/main/${CHANGELOG}"
} > "$RUNNER_TEMP/release_notes.md"

# A module in a subdirectory resolves through a tag named after it (src/vX.Y.Z), which OSS
# GoReleaser rejects as non-semver, so it is mirrored here onto the same commit. It matches no
# trigger. An alias already on GitHub is left alone: a module tag is permanent once fetched.
- name: Mirror the module alias tag
if: inputs.module-alias-prefix != ''
env:
PREFIX: ${{ inputs.module-alias-prefix }}
run: |
set -euo pipefail
alias_tag="${PREFIX%/}/${TAG}"
if git ls-remote --exit-code --tags origin "refs/tags/${alias_tag}" >/dev/null; then
echo "${alias_tag} already exists on the remote"
else
git tag "${alias_tag}" "${TAG}^{commit}"
git push origin "refs/tags/${alias_tag}"
echo "pushed ${alias_tag}"
fi

- name: Run GoReleaser
uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0
with:
version: '~> v2'
args: release --clean --config ${{ inputs.goreleaser-config }} --release-notes=${{ runner.temp }}/release_notes.md
env:
GITHUB_TOKEN: ${{ github.token }}
GORELEASER_CURRENT_TAG: ${{ inputs.tag }}

# Fail closed on an empty release body: read the published release back and require at least
# the changelog section passed in.
- name: Verify the published release notes
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
want=$(tr -d '[:space:]' < "$RUNNER_TEMP/section.md" | wc -c)
got=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' | tr -d '[:space:]' | wc -c)
echo "section=${want} body=${got} (non-space characters)"
if [ "$got" -lt "$want" ]; then
echo "::error::The ${TAG} release body is shorter than its CHANGELOG section (${got} < ${want})."
exit 1
fi

- name: Post-release assets
if: inputs.post-release != ''
env:
GH_TOKEN: ${{ github.token }}
POST_RELEASE: ${{ inputs.post-release }}
run: bash -euo pipefail -c "$POST_RELEASE"

- name: Attest build provenance
uses: actions/attest-build-provenance@v4
with:
subject-checksums: ${{ env.DIST }}/checksums.txt

- name: Check for extra assets to attest
id: extra
run: |
if [ -s "$RUNNER_TEMP/extra-checksums.txt" ]; then echo "present=true" >> "$GITHUB_OUTPUT"; fi

- name: Attest the extra assets
if: steps.extra.outputs.present == 'true'
uses: actions/attest-build-provenance@v4
with:
subject-checksums: ${{ runner.temp }}/extra-checksums.txt
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ that apply to every simtabi repository lacking its own copy.
- [Health files](docs/health-files.md) — where each health file lives, why, and how the org copies are rendered from the masters in [`templates/health/`](templates/health/).
- [The CI bot](bot/README.md) — `refresh-bot`, the one automation app: where its key lives, the per-job token policy, adding a job.
- [Dependabot control plane](dependabot/README.md) — the reconciler and the merge gate that run as the bot.
- [Go release workflow](go-release/README.md) — the reusable workflow every Go repository's `release.yml` calls: binaries on the GitHub release only, built on a version tag or on request.

## License

Expand Down
37 changes: 37 additions & 0 deletions go-release/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Go release workflow

`.github/workflows/go-release.yml` is the one release pipeline for every Go project in every
Simtabi-owned org. It builds the binaries and publishes them on the repository's GitHub release, and
nowhere else. The standard it implements is `~/.claude/rules/go.md`, *Go releases* (owner, 2026-11-13).

## Using it

Copy [`templates/go/release.yml`](../templates/go/release.yml) to the repository's
`.github/workflows/release.yml` and pin the `uses:` line to a commit of this repository. It runs on:

- a pushed `vX.Y.Z` or `vX.Y.Z-pre` tag: `git push origin vX.Y.Z`;
- a request naming a tag already on GitHub: `gh workflow run release.yml --repo OWNER/REPO -f tag=vX.Y.Z`.

Push a version tag only for final code: tags are final, and a pushed tag publishes.

## Inputs

| Input | Default | What it is |
|---|---|---|
| `tag` | required | the `vX.Y.Z` tag to build; it must already be on GitHub |
| `goreleaser-config` | `src/.goreleaser.yaml` | the GoReleaser config, from the repository root |
| `go-version-file` | `src/go.mod` | the `go.mod` that pins the Go version |
| `module-alias-prefix` | `src/` | the module's subdirectory, for its `src/vX.Y.Z` alias tag; empty for none |
| `changelog` | `CHANGELOG.md` | its `## [X.Y.Z]` section becomes the release notes |
| `post-release` | empty | a command run after GoReleaser to attach extra assets; it may write `$RUNNER_TEMP/extra-checksums.txt` to have them attested |

## What it refuses

- a tag that is not `vX.Y.Z[-pre]`, or is not on GitHub;
- a GoReleaser config holding any block that publishes elsewhere: `brews`, `homebrew_casks`,
`scoops`, `winget`, `chocolateys`, `aurs`, `aur_sources`, `snapcrafts`, `flatpaks`, `nix`,
`dockers`, `dockers_v2`, `docker_manifests`, `docker_signs`, `kos`, `krews`, `npms`,
`cloudsmiths`, `furies`, `publishers`, `blobs`, `uploads`, `artifactories`;
- a final version with no CHANGELOG section, and a published release whose body is shorter than it.

`go-release/tests/` pins all of this and the template's triggers; CI runs it with actionlint.
75 changes: 75 additions & 0 deletions go-release/tests/test_go_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Guards for the shared Go release: the reusable workflow and the caller template.

The standard (~/.claude/rules/go.md, "Go releases") says Go binaries ship on the GitHub release only,
built on a version tag or on request. These tests read the two files as text, with the standard
library only, the way the rest of this repository's tests do.
"""
import re
import unittest
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
WORKFLOW = ROOT / ".github" / "workflows" / "go-release.yml"
TEMPLATE = ROOT / "templates" / "go" / "release.yml"

# Every GoReleaser block that publishes somewhere other than the GitHub release.
CHANNELS = {
"brews", "homebrew_casks", "scoops", "winget", "chocolateys", "aurs", "aur_sources",
"snapcrafts", "flatpaks", "nix", "dockers", "dockers_v2", "docker_manifests", "docker_signs",
"kos", "krews", "npms", "cloudsmiths", "furies", "publishers", "blobs", "uploads", "artifactories",
}


def triggers(text):
"""The top-level keys under `on:`, in order."""
block = re.search(r"^on:\n((?:[ \t]+.*\n|\n)+)", text, re.M)
if not block:
return []
return re.findall(r"^ ([a-z_]+):", block.group(1), re.M)


class SharedWorkflow(unittest.TestCase):
def setUp(self):
self.text = WORKFLOW.read_text()

def test_it_is_only_ever_called(self):
self.assertEqual(triggers(self.text), ["workflow_call"])

def test_the_tag_is_a_required_input(self):
self.assertRegex(self.text, r"\n tag:\n(?: .*\n)*? required: true")

def test_it_refuses_every_other_channel(self):
m = re.search(r"banned='([^']+)'", self.text)
self.assertIsNotNone(m, "the channel check is gone")
self.assertEqual(set(m.group(1).split("|")), CHANNELS)

def test_it_checks_out_the_tag_and_attests(self):
self.assertIn("ref: refs/tags/${{ inputs.tag }}", self.text)
self.assertIn("actions/attest-build-provenance", self.text)
self.assertIn("GORELEASER_CURRENT_TAG: ${{ inputs.tag }}", self.text)


class CallerTemplate(unittest.TestCase):
def setUp(self):
self.text = TEMPLATE.read_text()

def test_it_runs_on_a_version_tag_or_on_request(self):
self.assertEqual(triggers(self.text), ["push", "workflow_dispatch"])
push = re.search(r"^ push:\n((?: .*\n)+)", self.text, re.M).group(1)
self.assertNotIn("branches", push)
tags = re.findall(r"^ - '([^']+)'", push, re.M)
self.assertTrue(tags, "no tag filter")
for t in tags:
self.assertRegex(t, r"^v\[0-9\]\+\.", f"tag filter {t!r} is not a version")

def test_a_dispatch_names_the_tag(self):
self.assertRegex(self.text, r"workflow_dispatch:\n inputs:\n tag:")

def test_it_calls_the_shared_workflow(self):
self.assertIn("uses: simtabi/.github/.github/workflows/go-release.yml@", self.text)
for perm in ("contents: write", "id-token: write", "attestations: write"):
self.assertIn(perm, self.text)


if __name__ == "__main__":
unittest.main()
37 changes: 37 additions & 0 deletions templates/go/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Release: build this repository's binaries and publish them on its GitHub release, and nowhere
# else. The work is done by simtabi/.github's reusable go-release.yml; this file only says when.
#
# git push origin vX.Y.Z builds and publishes vX.Y.Z
# gh workflow run release.yml --repo OWNER/REPO -f tag=vX.Y.Z the same, for a tag already pushed
#
# Push a version tag only for final code: tags are final, and a pushed tag publishes.
name: Release

on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-*'
workflow_dispatch:
inputs:
tag:
description: 'An existing vX.Y.Z tag to build and publish'
required: true
type: string

permissions: {}

# One run per tag, never cancelled: two runs must not publish the same release.
concurrency:
group: release-${{ inputs.tag || github.ref_name }}
cancel-in-progress: false

jobs:
release:
permissions:
contents: write
id-token: write
attestations: write
uses: simtabi/.github/.github/workflows/go-release.yml@main # pin to a commit when copying
with:
tag: ${{ inputs.tag || github.ref_name }}
Loading