diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 6c56b60..228ee52 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -1,15 +1,14 @@ # Release automation. release-please reads the Conventional Commit log since the last tag, works out # the next version, and keeps one release pull request open that bumps it. Merging that pull request -# tags the release. The tag is git only: it starts no binary release (release.yml is manual, -# behind an explicit confirmation, until a binary release is decided on). +# tags the release, and that tag starts release.yml, which publishes the binaries on the GitHub +# release (owner, 2026-11-13: GitHub release only, built on a tag or on request). # # Versioning on 0.x: a breaking change (`feat!:`, `BREAKING CHANGE:`) bumps the minor, everything # else bumps the patch. icon-sets-sync is versioned independently of the coupled ichava packages. # # Release notes stay hand-written. release-please is configured with `skip-changelog`, so it never # writes CHANGELOG.md; the step below closes the hand-written `## [Unreleased]` section under the new -# version on the release branch; release.yml would publish that section as the release body if it is -# ever dispatched. +# version on the release branch; release.yml publishes that section as the release body. # # Triggers. This repository's rule is that the only `push` trigger is a tag, and release-please does # not need one: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5f7519..ea7b3ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,213 +1,41 @@ -# Manual only. icon-sets-sync's binaries are published on the GitHub release, and -# nowhere else: no `go install` path, Homebrew tap, Scoop bucket or image (owner, -# 2026-11-13). Pushing a `vX.Y.Z` tag records the version and starts nothing; a -# release is cut when the maintainers dispatch this workflow for that version. +# Release: build icon-sets-sync's binaries and publish them on its GitHub release, and nowhere else +# (no `go install` path, Homebrew, Scoop, Winget, image or any other channel; owner, 2026-11-13). +# The pipeline is simtabi/.github's shared go-release.yml; this file only says when, and adds the +# double-click launchers (.scripts/release-launchers.sh) after GoReleaser. # -# It runs only when dispatched against a tag with the explicit confirmation input: +# git push origin vX.Y.Z builds and publishes vX.Y.Z +# gh workflow run release.yml --repo ichava/icon-sets-sync -f tag=vX.Y.Z the same, for a tag already pushed # -# gh workflow run release.yml --repo ichava/icon-sets-sync --ref vX.Y.Z -f confirm=release-go -# -# Any other value stops the run before anything is built or published. When it does run: -# -# - GoReleaser cross-compiles icon-sets-sync for the -# whole matrix, builds the bare binaries, the .tar.gz/.zip archives, the -# deb/rpm/apk packages and a reproducible source tarball, an SPDX SBOM per -# archive, and checksums.txt over all of it (config: src/.goreleaser.yaml); -# - cosign signs checksums.txt keylessly through Sigstore; -# - the double-click launchers (the macOS app bundle, zipped, and the Windows -# GUI launchers) are built from the same binaries and attached, with their -# own checksums-launchers.txt; -# - attest-build-provenance signs a build-provenance attestation for every -# artifact listed in either checksums file. -# -# The release body is the tagged version's CHANGELOG section, never generated -# notes alone. - +# Push a version tag only for final code: tags are final, and a pushed tag publishes. The shared +# workflow also pushes the src/vX.Y.Z module tag, which matches no trigger here. name: Release on: - # No `push: tags` trigger: a tag is git only until a binary release is decided on. + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-*' workflow_dispatch: inputs: - confirm: - description: 'Type release-go to publish binaries for the tag this run is dispatched against' + tag: + description: 'An existing vX.Y.Z tag to build and publish' required: true - default: 'no' + type: string + +permissions: {} +# One run per tag, never cancelled: two runs must not publish the same release. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - # Never cancel a release in flight. A half-published release is worse than a - # queued one. + group: release-${{ inputs.tag || github.ref_name }} cancel-in-progress: false -permissions: - contents: read - jobs: release: - name: Publish release - runs-on: ubuntu-latest - timeout-minutes: 30 - # Our own modules come from git, never through the public proxy, so a CI - # build never feeds proxy.golang.org or sum.golang.org a tag. - env: - GOPRIVATE: github.com/ichava/* - GONOSUMDB: github.com/ichava/* permissions: - contents: write # create the release, upload assets, push the src/ alias tag - id-token: write # Sigstore keyless identity, for cosign and the attestation - attestations: write # store the build-provenance attestation - - steps: - # A release is cut only when the maintainers dispatch it for a version. - # Refuse without the explicit confirmation, and refuse on anything but a tag. - - name: Require the explicit release confirmation - env: - CONFIRM: ${{ inputs.confirm }} - REF_TYPE: ${{ github.ref_type }} - run: | - set -euo pipefail - if [ "$CONFIRM" != "release-go" ]; then - echo "::error::Dispatch with -f confirm=release-go, for a version the maintainers have decided to release." - exit 1 - fi - if [ "$REF_TYPE" != "tag" ]; then - echo "::error::Dispatch this workflow against a vX.Y.Z tag (--ref vX.Y.Z)." - exit 1 - fi - - - name: Checkout - uses: actions/checkout@v7 - with: - fetch-depth: 0 # GoReleaser reads the tag and the history - - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version-file: src/go.mod - cache-dependency-path: src/go.sum - - # syft generates GoReleaser's per-archive SBOMs. - - 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 tag whose version has no CHANGELOG section is a tag on the - # wrong commit or a release nobody wrote notes for; stop rather than publish - # generated notes. Written outside the checkout: GoReleaser refuses to - # release from a dirty tree, and an untracked notes file is one. - - name: Extract the changelog section - run: | - set -euo pipefail - VERSION="${GITHUB_REF_NAME#v}" - awk -v ver="$VERSION" ' - $0 ~ "^## \\[" ver "\\]" { found = 1; next } - found && /^## \[/ { exit } - found { print } - ' CHANGELOG.md > "$RUNNER_TEMP/section.md" - # A pre-release tag (vX.Y.Z-rc.N) is a test build: GoReleaser marks it a - # pre-release (`prerelease: auto`) and it needs no CHANGELOG section of its - # own. A final tag still fails closed below. - if [ -z "$(tr -d '[:space:]' < "$RUNNER_TEMP/section.md")" ] && [[ "$VERSION" == *-* ]]; then - echo "Pre-release build ${GITHUB_REF_NAME}, for testing. It is not a release; the notes ship with the final v${VERSION%%-*} tag." > "$RUNNER_TEMP/section.md" - fi - if [ -z "$(tr -d '[:space:]' < "$RUNNER_TEMP/section.md")" ]; then - echo "::error::CHANGELOG.md has no '## [$VERSION]' section. Refusing to publish $GITHUB_REF_NAME." - exit 1 - fi - { - cat "$RUNNER_TEMP/section.md" - echo '' - echo "**Full changelog:** https://github.com/ichava/icon-sets-sync/blob/main/CHANGELOG.md" - } > "$RUNNER_TEMP/release_notes.md" - - # Go resolves this module (github.com/ichava/icon-sets-sync/src) through a - # tag named after its subdirectory, src/vX.Y.Z, and ignores vX.Y.Z. OSS - # GoReleaser rejects the prefixed tag as non-semver, so the release runs - # off vX.Y.Z and mirrors src/vX.Y.Z onto the same commit here, where it - # cannot be forgotten. src/... matches neither trigger above, so pushing - # it starts no second run. - # - # Before GoReleaser rather than after: `go install` builds from source and - # needs no assets, so the alias on a release that later failed is still - # self-consistent, while a release without its alias is not installable - # at all. A re-run finds the tag already there and leaves it alone -- - # a src/ tag is permanent once the Go proxy has fetched it, so this step - # never moves one. - - name: Mirror the tag Go resolves the module through - run: | - set -euo pipefail - alias_tag="src/${GITHUB_REF_NAME}" - 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}" "${GITHUB_REF_NAME}^{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 src/.goreleaser.yaml --release-notes=${{ runner.temp }}/release_notes.md - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # Fail closed on an empty release body. v0.1.0 and v0.1.1 shipped with a - # blank body because `changelog.disable: true` made GoReleaser drop - # --release-notes; nothing in the run noticed. Read the published release - # back and require at least the changelog section we passed in. - - name: Verify the published release notes - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - want=$(tr -d '[:space:]' < "$RUNNER_TEMP/section.md" | wc -c) - got=$(gh release view "$GITHUB_REF_NAME" --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 ${GITHUB_REF_NAME} release body is shorter than its CHANGELOG section (${got} < ${want})." - exit 1 - fi - - # GoReleaser does not build the double-click launchers: the macOS app bundle - # around the universal binary it just built, and the Windows GUI launchers - # (src/internal/tools/launchers, as `make app` and `make cross` run it). The - # .app is a folder, so it ships zipped. They get their own checksums file, - # written outside the folder so it does not list itself, and are attested - # below with everything else. - - name: Build and attach the launchers - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - out="$RUNNER_TEMP/launchers" - mkdir -p "$out" - # The published icon-sets-sync_macos_universal asset, at the build path - # GoReleaser recorded for it (flatdist reads artifacts.json the same way). - universal=$(jq -r '.[] | select(.name=="icon-sets-sync_macos_universal") | .path' .cache/goreleaser/artifacts.json) - [ -f "$universal" ] || { echo "::error::no universal binary in .cache/goreleaser/artifacts.json"; exit 1; } - go -C src run ./internal/tools/launchers \ - -app "$out/icon-sets-sync.app" \ - -universal "../$universal" \ - -version "${GITHUB_REF_NAME#v}" \ - -windows "$out" - (cd "$out" && zip -qry icon-sets-sync_macos_universal.app.zip icon-sets-sync.app && rm -rf icon-sets-sync.app) - (cd "$out" && sha256sum -- * > "$RUNNER_TEMP/checksums-launchers.txt") - mv "$RUNNER_TEMP/checksums-launchers.txt" "$out/" - ls -l "$out" - [ "$(find "$out" -name '*.exe' | wc -l)" -ge 3 ] || { echo "::error::expected at least 3 Windows launchers"; exit 1; } - gh release upload "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" "$out"/* - - - name: Attest build provenance - uses: actions/attest-build-provenance@v4 - with: - subject-checksums: .cache/goreleaser/checksums.txt - - - name: Attest the launchers - uses: actions/attest-build-provenance@v4 - with: - subject-checksums: ${{ runner.temp }}/launchers/checksums-launchers.txt + contents: write + id-token: write + attestations: write + uses: simtabi/.github/.github/workflows/go-release.yml@a1dca588d5c64798085a5fad57c872f6ce02e9aa # simtabi/.github#27 + with: + tag: ${{ inputs.tag || github.ref_name }} + post-release: bash .scripts/release-launchers.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e63af34..f8df4d0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -173,9 +173,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 - # Nothing else reads these files until they run. `release.yml` runs only when - # dispatched against a tag, so without this a broken workflow is first observed - # as a release that refused to start -- after the decision to release was made. + # Nothing else reads these files until they run. `release.yml` runs only on a + # version tag or a dispatch naming one, so without this a broken workflow is first + # observed as a release that refused to start -- after the decision to release was made. # # A YAML parse is not a substitute. `yaml.safe_load` accepts a duplicate key # and silently keeps the last one, which is how a double-applied patch shipped @@ -198,7 +198,7 @@ jobs: GOPRIVATE: github.com/ichava/* GONOSUMDB: github.com/ichava/* - # release.yml only runs when dispatched against a tag, so without this the + # release.yml only runs on a version tag (or a dispatch naming one), so without this the # first sight of a broken GoReleaser config, a target that stopped compiling or an archive # that lost a file is a tag that published nothing. This builds the whole # release matrix the way release.yml does, minus the publish and the diff --git a/.scripts/release-launchers.sh b/.scripts/release-launchers.sh new file mode 100755 index 0000000..606b987 --- /dev/null +++ b/.scripts/release-launchers.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Build the double-click launchers for a release and attach them to it. Run by the shared +# simtabi/.github go-release.yml as its post-release command, from the repository root, after +# GoReleaser has published the release. GoReleaser does not build these: the macOS app bundle +# around the universal binary it just built, zipped (an .app is a folder), and the Windows GUI +# launchers (src/internal/tools/launchers, as `make app` and `make cross` run it). +# +# Needs TAG, VERSION, DIST (GoReleaser's dist directory), GH_TOKEN, GITHUB_REPOSITORY and +# RUNNER_TEMP, all set in the shared workflow. Writes checksums-launchers.txt beside the assets and +# attaches it too, and copies it to $RUNNER_TEMP/extra-checksums.txt so the launchers are attested. +set -euo pipefail +: "${TAG:?}" "${VERSION:?}" "${DIST:?}" "${GH_TOKEN:?}" "${GITHUB_REPOSITORY:?}" "${RUNNER_TEMP:?}" + +out="$RUNNER_TEMP/launchers" +mkdir -p "$out" +# The published icon-sets-sync_macos_universal asset, at the build path GoReleaser recorded for it +# (flatdist reads artifacts.json the same way). +universal=$(jq -r '.[] | select(.name=="icon-sets-sync_macos_universal") | .path' "$DIST/artifacts.json") +[ -f "$universal" ] || { echo "::error::no universal binary in $DIST/artifacts.json"; exit 1; } +go -C src run ./internal/tools/launchers \ + -app "$out/icon-sets-sync.app" \ + -universal "../$universal" \ + -version "$VERSION" \ + -windows "$out" +(cd "$out" && zip -qry icon-sets-sync_macos_universal.app.zip icon-sets-sync.app && rm -rf icon-sets-sync.app) +# Written outside the folder first, so it does not list itself. +(cd "$out" && sha256sum -- * > "$RUNNER_TEMP/checksums-launchers.txt") +mv "$RUNNER_TEMP/checksums-launchers.txt" "$out/" +[ "$(find "$out" -name '*.exe' | wc -l)" -ge 3 ] || { echo "::error::expected at least 3 Windows launchers"; exit 1; } +ls -l "$out" +gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" "$out"/* +cp "$out/checksums-launchers.txt" "$RUNNER_TEMP/extra-checksums.txt" diff --git a/CHANGELOG.md b/CHANGELOG.md index be14666..98db59a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ The Go module proxy and checksum database already hold `github.com/ichava/icon-s ### Changed +- **A release builds on a pushed version tag, or on request** (owner, 2026-11-13). `release.yml` now holds only those two triggers (`push` of `v[0-9]+.[0-9]+.[0-9]+[-*]`, and `workflow_dispatch -f tag=vX.Y.Z`, replacing the `-f confirm=release-go` gate) and calls `simtabi/.github`'s shared `go-release.yml`, which every Go project uses; the app and launcher step moves to `.scripts/release-launchers.sh`, run as its `post-release`, and now reads the tag from the shared workflow instead of `GITHUB_REF_NAME`, which is a branch on a dispatched run. `TestReleaseRunsOnAVersionTagOrOnRequest` replaces `TestReleaseRunsOnlyOnAConfirmedDispatch` and fails on any other trigger, a branch push, or a pipeline of its own (proven red all three ways). - **Binaries are published on the GitHub release, and nowhere else** (owner, 2026-11-13). A release, still dispatched by hand with `-f confirm=release-go` for a version the maintainers choose, now also attaches the macOS app (`icon-sets-sync_macos_universal.app.zip`) and the three Windows launchers, built from the released universal binary, with their own `checksums-launchers.txt` and build-provenance attestation. The README and `docs/installation.md` offer the download first and the source build second; there is still no `go install` path, tap, bucket or image, and `TestGoReleaserPublishesNowhereElse` fails if `src/.goreleaser.yaml` gains one (proven red with a `brews` block). The launcher step was run locally against a GoReleaser snapshot: four assets, all verified by their checksums file. - **CI builds the binaries and the macOS app into `build/` and uploads it.** The Tests workflow's release snapshot is now laid out the way `make cross` and `make app` do it: `build/{os}/` with the 19 binaries, the Linux `.desktop` entry, the Windows launchers and `icon-sets-sync.app`. `check-build` checks it, GoReleaser's scratch directory is gone afterwards, and `build/` is the run's `icon-sets-sync-build` artifact (14 days). Nothing is published; `release.yml` is unchanged. The same sequence run locally gave 37 entries. - **`.scripts/sync-svg-policy.mjs` checks icon-browser's copy of the SVG policy** (react consolidation 3f). The browser UI's source moved into icon-browser at 3d, so the script reads `icon-browser/resources/js/core/svg-policy.json` instead of the retired `react-icon-browser/src/core/svg-policy.json`. Against core, icon-browser and this repository at `main` it exits 0. `syncscript_test.go` pins the path and runs the script over a temporary workspace, passing on matching copies and failing on a drifted icon-browser copy (both tests proven red against the old path). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 554f614..d8fca09 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,10 +53,10 @@ there is how this tool spent weeks re-opening a pull request it had already open ## Releases and versioning -- **Binaries are published on the GitHub release only, and only when the maintainers say so.** A - `vX.Y.Z` tag starts no workflow. `release.yml` is manual and refuses to run without - `-f confirm=release-go`; do not dispatch it, or restore a `push: tags` trigger, without an explicit - maintainer decision for that version. Never add a `go install` path, tap, bucket or image. See +- **Binaries are published on the GitHub release only.** A pushed `vX.Y.Z` tag builds and publishes + the release, and so does `gh workflow run release.yml -f tag=vX.Y.Z`; push a version tag only for + final code. `release.yml` calls `simtabi/.github`'s shared `go-release.yml`: change the pipeline + there, never here. Never add a `go install` path, tap, bucket, image or any other channel. See [Release](docs/release.md#binaries-are-published-on-the-github-release). - **A version tag is pushed only for final code.** `vX.Y.Z` marks a release, nothing else. Test a change on a branch; a pre-release tag (`vX.Y.Z-rc.N`) is for the day binary releases are on. diff --git a/docs/installation.md b/docs/installation.md index 7f5bf27..8c2ad48 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -179,7 +179,7 @@ the repository root finds no packages. taken from commits before the single-commit publication, so it would serve older code. See [Release](release.md#where-this-package-is). -Release binaries come from `release.yml`, dispatched by hand for each version; see +Release binaries come from `release.yml`, which runs on a pushed version tag or on request; see [Release](release.md#binaries-are-published-on-the-github-release). ## What a sync needs diff --git a/docs/release.md b/docs/release.md index c7e0453..aafe2b7 100644 --- a/docs/release.md +++ b/docs/release.md @@ -21,19 +21,21 @@ source at a pinned tag or commit; see [Maintainer sync](maintainer-sync.md#3-wor **The GitHub release is the only channel for binaries** (owner, 2026-11-13): no `go install` path, Homebrew tap, Scoop bucket, Winget manifest or image, and `TestGoReleaserPublishesNowhereElse` fails if -`src/.goreleaser.yaml` gains one. A `vX.Y.Z` tag on its own records the version and starts nothing; -the release is cut when the maintainers dispatch it for that version. `release.yml` is manual: its only trigger is `workflow_dispatch`, and its first step -refuses to run unless `confirm` is `release-go` and the run was dispatched against a tag. -`TestReleaseRunsOnlyOnAConfirmedDispatch` fails if it gains another trigger or loses the -confirmation. When a binary release is decided on, dispatch it against a tag that already exists on -GitHub, from any directory: +`src/.goreleaser.yaml` gains one. **A release builds on a pushed `vX.Y.Z` tag, or on request** for a tag +already on GitHub, from any directory: ```bash -gh workflow run release.yml --repo ichava/icon-sets-sync --ref vX.Y.Z -f confirm=release-go +git -C ~/Artisan/projects/opensource/ichava/packages/icon-sets-sync push origin vX.Y.Z # builds and publishes +gh workflow run release.yml --repo ichava/icon-sets-sync -f tag=vX.Y.Z # the same, on request ``` -That command publishes for real, so run it only for a version the maintainers approved. Pushing the -tag alone publishes nothing. +Both publish for real, so push a version tag only for final code: tags are final. `release.yml` +holds only those two triggers and calls `simtabi/.github`'s shared `go-release.yml`, which does the +work for every Go project (the tag check, the channel refusal, the CHANGELOG notes, the `src/vX.Y.Z` +alias, GoReleaser and provenance); `.scripts/release-launchers.sh` then adds the app and the Windows +launchers. `TestReleaseRunsOnAVersionTagOrOnRequest` fails on any other trigger, a branch push or a +pipeline of its own. release-please's tag counts as a pushed tag: it is made with the Refresh Bot +token, so it starts the release. To build the whole matrix on your own machine without publishing anything, run `make cross` (GoReleaser v2 on `PATH`). It reads the same `src/.goreleaser.yaml`, builds into the scratch directory @@ -70,8 +72,7 @@ else bumps the patch. icon-sets-sync is versioned independently of the coupled i Never edit the release pull request by hand: release-please rebuilds its branch and drops anything it did not write. Change the notes on `main` instead. -When a binary release is decided on, `release.yml` is dispatched by hand against the tag (above). -It publishes binaries only, no container image: +A release (on the tag, or on request; above) publishes binaries only, no container image: | Workflow | Publishes | |---|---| diff --git a/src/internal/cli/no_docker_test.go b/src/internal/cli/no_docker_test.go index 51ed562..1ac1ea3 100644 --- a/src/internal/cli/no_docker_test.go +++ b/src/internal/cli/no_docker_test.go @@ -117,43 +117,69 @@ func TestNoWorkflowBuildsOrPublishesAnImage(t *testing.T) { } } -// release.yml publishes binaries only when a person dispatches it against a -// tag with confirm=release-go. A tag, a schedule, a pull request or a release -// event must never start it. -func TestReleaseRunsOnlyOnAConfirmedDispatch(t *testing.T) { +// release.yml publishes a release on a pushed version tag, or on request for a tag already +// pushed, and does nothing else: its triggers are exactly those two, and its one job calls the +// shared simtabi/.github go-release.yml (owner, 2026-11-13). A branch push, a schedule, a pull +// request or a release event must never start it. +func TestReleaseRunsOnAVersionTagOrOnRequest(t *testing.T) { raw, err := os.ReadFile(filepath.Join(filepath.FromSlash(repoRoot), ".github", "workflows", "release.yml")) if err != nil { t.Fatal(err) } var wf struct { - On map[string]struct { - Inputs map[string]struct { - Required bool `yaml:"required"` - } `yaml:"inputs"` + On struct { + Push *struct { + Tags []string `yaml:"tags"` + Branches []string `yaml:"branches"` + } `yaml:"push"` + Dispatch *struct { + Inputs map[string]struct { + Required bool `yaml:"required"` + } `yaml:"inputs"` + } `yaml:"workflow_dispatch"` } `yaml:"on"` - Jobs map[string]workflowJob `yaml:"jobs"` + Jobs map[string]struct { + Uses string `yaml:"uses"` + With map[string]string `yaml:"with"` + } `yaml:"jobs"` } if err := yaml.Unmarshal(raw, &wf); err != nil { t.Fatal(err) } + var all struct { + On map[string]any `yaml:"on"` + } + if err := yaml.Unmarshal(raw, &all); err != nil { + t.Fatal(err) + } var triggers []string - for k := range wf.On { + for k := range all.On { triggers = append(triggers, k) } sort.Strings(triggers) - if strings.Join(triggers, ",") != "workflow_dispatch" { - t.Fatalf("release.yml triggers %v, want only workflow_dispatch", triggers) + if strings.Join(triggers, ",") != "push,workflow_dispatch" { + t.Fatalf("release.yml triggers %v, want exactly push (version tags) and workflow_dispatch", triggers) + } + if wf.On.Push == nil || len(wf.On.Push.Branches) > 0 || len(wf.On.Push.Tags) == 0 { + t.Fatal("release.yml must push-trigger on version tags only, never on a branch") + } + for _, tag := range wf.On.Push.Tags { + if !strings.HasPrefix(tag, "v[0-9]+.") { + t.Errorf("tag filter %q is not a version tag", tag) + } + } + if wf.On.Dispatch == nil { + t.Fatal("release.yml has no workflow_dispatch") } - if in, ok := wf.On["workflow_dispatch"].Inputs["confirm"]; !ok || !in.Required { - t.Fatal("release.yml has no required confirm input") + if in, ok := wf.On.Dispatch.Inputs["tag"]; !ok || !in.Required { + t.Fatal("release.yml has no required tag input on workflow_dispatch") } job, ok := wf.Jobs["release"] - if !ok || len(job.Steps) == 0 { - t.Fatal("release.yml has no release job") + if !ok || !strings.HasPrefix(job.Uses, "simtabi/.github/.github/workflows/go-release.yml@") { + t.Fatalf("the release job must call the shared go-release.yml; it uses %q", job.Uses) } - first := job.Steps[0].Run - if !strings.Contains(first, `"release-go"`) || !strings.Contains(first, `"tag"`) { - t.Errorf("the first release step must refuse without confirm=release-go on a tag; it runs:\n%s", first) + if job.With["tag"] != "${{ inputs.tag || github.ref_name }}" { + t.Errorf("the release job passes tag %q", job.With["tag"]) } }