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
31 changes: 29 additions & 2 deletions .github/workflows/go-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
# with:
# tag: ${{ inputs.tag || github.ref_name }}
#
# Optional: runs-on (a macOS runner for a project whose darwin build needs cgo), environment (a
# GitHub environment the job runs under, such as `release`), and the SIGNPATH_API_TOKEN secret,
# passed explicitly (`secrets: SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}`, never
# `secrets: inherit`) for a GoReleaser config that signs through SignPath.
#
# 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
Expand Down Expand Up @@ -53,13 +58,29 @@ on:
required: false
type: string
default: ''
runs-on:
description: 'Runner label for the job, such as macos-latest for a darwin build that needs cgo'
required: false
type: string
default: ubuntu-latest
environment:
description: 'GitHub environment the job runs under, such as release; empty for none'
required: false
type: string
default: ''
secrets:
SIGNPATH_API_TOKEN:
description: 'SignPath API token, exported to GoReleaser for a config that signs through SignPath'
required: false

permissions: {}

jobs:
release:
name: Publish release
runs-on: ubuntu-latest
runs-on: ${{ inputs.runs-on }}
# An empty name runs the job under no environment.
environment: ${{ inputs.environment }}
timeout-minutes: 40
permissions:
contents: write # create the release, upload assets, push the module alias tag
Expand Down Expand Up @@ -173,6 +194,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
GORELEASER_CURRENT_TAG: ${{ inputs.tag }}
# Empty unless the caller passes it; only a config that signs through SignPath reads it.
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}

# Fail closed on an empty release body: read the published release back and require at least
# the changelog section passed in.
Expand All @@ -196,7 +219,11 @@ jobs:
POST_RELEASE: ${{ inputs.post-release }}
run: bash -euo pipefail -c "$POST_RELEASE"

# GitHub Free refuses artifact attestations on a private repository, so both attestation
# steps run on public repositories only. github.event.repository is the caller's repository,
# set on the push and workflow_dispatch events that start a release.
- name: Attest build provenance
if: github.event.repository.private == false
uses: actions/attest-build-provenance@v4
with:
subject-checksums: ${{ env.DIST }}/checksums.txt
Expand All @@ -207,7 +234,7 @@ jobs:
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'
if: steps.extra.outputs.present == 'true' && github.event.repository.private == false
uses: actions/attest-build-provenance@v4
with:
subject-checksums: ${{ runner.temp }}/extra-checksums.txt
14 changes: 14 additions & 0 deletions go-release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ Push a version tag only for final code: tags are final, and a pushed tag publish
| `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 |
| `runs-on` | `ubuntu-latest` | the runner label for the job; `macos-latest` for a project whose darwin build needs cgo |
| `environment` | empty | a GitHub environment the job runs under (for example `release`); empty for none |

## Secrets

| Secret | Required | What it is |
|---|---|---|
| `SIGNPATH_API_TOKEN` | no | exported to the GoReleaser step for a config that signs through SignPath. Pass it explicitly, `secrets: SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}`, never `secrets: inherit` |

## Attestations

Build provenance is attested for `checksums.txt` and any extra assets on a **public** repository
only: GitHub Free refuses attestations on a private one, so both steps skip when
`github.event.repository.private` is true. The release itself still publishes.

## What it refuses

Expand Down
36 changes: 36 additions & 0 deletions go-release/tests/test_go_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,42 @@ def test_it_checks_out_the_tag_and_attests(self):
self.assertIn("actions/attest-build-provenance", self.text)
self.assertIn("GORELEASER_CURRENT_TAG: ${{ inputs.tag }}", self.text)

def input_block(self, name):
m = re.search(r"\n " + re.escape(name) + r":\n((?: .*\n)+)", self.text)
self.assertIsNotNone(m, f"no {name} input")
return m.group(1)

def job(self):
return re.search(r"^jobs:\n((?:[ \t]+.*\n|\n)+)", self.text, re.M).group(1)

def test_the_runner_is_an_input_defaulting_to_ubuntu(self):
block = self.input_block("runs-on")
self.assertIn("required: false", block)
self.assertRegex(block, r"default: ['\"]?ubuntu-latest['\"]?\n")
self.assertRegex(self.job(), r"\n runs-on: \$\{\{ inputs\.runs-on \}\}\n")

def test_the_environment_is_an_optional_input(self):
block = self.input_block("environment")
self.assertIn("required: false", block)
self.assertRegex(block, r"default: ''\n")
self.assertRegex(self.job(), r"\n environment: \$\{\{ inputs\.environment \}\}\n")

def test_the_signpath_token_is_an_optional_secret_for_goreleaser(self):
secrets = re.search(r"\n secrets:\n((?: .*\n)+)", self.text)
self.assertIsNotNone(secrets, "workflow_call declares no secrets")
m = re.search(r" SIGNPATH_API_TOKEN:\n((?: .*\n)+)", secrets.group(1))
self.assertIsNotNone(m, "no SIGNPATH_API_TOKEN secret")
self.assertIn("required: false", m.group(1))
step = re.search(r"- name: Run GoReleaser\n((?: .*\n)+)", self.text).group(1)
self.assertIn("SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}", step)

def test_attestations_skip_a_private_repository(self):
steps = re.findall(r"\n((?: - name: .*\n)(?: .*\n)*)", self.text)
attest = [st for st in steps if "actions/attest-build-provenance" in st]
self.assertEqual(len(attest), 2, "expected two attestation steps")
for st in attest:
self.assertRegex(st, r"\n if: .*github\.event\.repository\.private == false", st)


class CallerTemplate(unittest.TestCase):
def setUp(self):
Expand Down
Loading