diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4fab60d..33a2dd5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,14 @@ name: Release on: - push: - tags: - - "v*" + release: + types: + - published workflow_dispatch: + inputs: + tag: + description: "Release tag to build, such as v0.2.0" + required: true permissions: contents: write @@ -25,9 +29,12 @@ jobs: goarch: arm64 env: CGO_ENABLED: "0" + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} steps: - name: Check out code uses: actions/checkout@v6 + with: + ref: ${{ github.event.release.tag_name || inputs.tag }} - name: Set up Go uses: actions/setup-go@v6 @@ -46,8 +53,13 @@ jobs: mkdir -p dist binary_name="actupdate" asset_dir="dist/actupdate_${GOOS}_${GOARCH}" + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "release tag must be a stable semver tag like v0.2.0" >&2 + exit 1 + fi + version="${RELEASE_TAG#v}" mkdir -p "${asset_dir}" - go build -o "${asset_dir}/${binary_name}" ./cmd/actupdate + go build -ldflags "-X main.version=${version}" -o "${asset_dir}/${binary_name}" ./cmd/actupdate tar -C dist -czf "${asset_dir}.tar.gz" "actupdate_${GOOS}_${GOARCH}" - name: Upload release artifact @@ -74,4 +86,5 @@ jobs: - name: Publish GitHub release assets uses: softprops/action-gh-release@v3 with: + tag_name: ${{ github.event.release.tag_name || inputs.tag }} files: release-assets/*.tar.gz diff --git a/README.md b/README.md index 6241d18..1344c97 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,10 @@ GITHUB_TOKEN="$(gh auth token)" actupdate ## Releases -Push a tag like `v0.1.0` to trigger the release workflow. It builds and uploads -tarballs for: +Create and publish a GitHub release with a tag like `v0.2.0` from +. The release workflow builds +the binaries from that tag, injects the tag version into `actupdate version`, +and uploads tarballs for: - `linux/amd64` - `linux/arm64` @@ -72,6 +74,12 @@ tarballs for: Each release asset is named like `actupdate_linux_amd64.tar.gz` and contains the `actupdate` binary. +If a release build needs to be rerun, dispatch the release workflow manually and +provide the existing release tag. + +Source builds report Go build metadata, such as a tagged version, pseudo-version, +or `devel-` fallback when no release version is injected. + ## Verification Rules - Only stable semver tags are considered diff --git a/cmd/actupdate/main.go b/cmd/actupdate/main.go index 7e96faa..44d005f 100644 --- a/cmd/actupdate/main.go +++ b/cmd/actupdate/main.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "path/filepath" + "runtime/debug" "strings" "time" @@ -21,7 +22,7 @@ import ( "golang.org/x/term" ) -const version = "0.1.0" +var version string const ( exitOK = iota @@ -31,6 +32,7 @@ const ( ) const maxCooldownDays = int64(math.MaxInt64 / int64(24*time.Hour)) +const shortRevisionLength = 12 type cliOptions struct { Repo string @@ -54,7 +56,7 @@ func run(args []string, in io.Reader, out, errOut io.Writer, httpClient *http.Cl return exitInvalidInput } if opts == nil { - fmt.Fprintln(out, version) + fmt.Fprintln(out, displayVersion()) return exitOK } @@ -210,6 +212,46 @@ func resolveToken(explicit string) string { return os.Getenv("GH_TOKEN") } +func displayVersion() string { + if version != "" { + return version + } + if info, ok := debug.ReadBuildInfo(); ok { + if buildVersion := versionFromBuildInfo(info); buildVersion != "" { + return buildVersion + } + } + return "unknown" +} + +func versionFromBuildInfo(info *debug.BuildInfo) string { + if info.Main.Version != "" && info.Main.Version != "(devel)" { + return info.Main.Version + } + + var revision string + modified := false + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + revision = setting.Value + case "vcs.modified": + modified = setting.Value == "true" + } + } + if revision == "" { + return "" + } + if len(revision) > shortRevisionLength { + revision = revision[:shortRevisionLength] + } + out := "devel-" + revision + if modified { + out += "+dirty" + } + return out +} + func promptConfirm(in io.Reader, out io.Writer) (bool, error) { fmt.Fprint(out, "Apply these updates? [Y/n]: ") reader := bufio.NewReader(in) diff --git a/cmd/actupdate/main_test.go b/cmd/actupdate/main_test.go index 795e6d5..d8b0bfc 100644 --- a/cmd/actupdate/main_test.go +++ b/cmd/actupdate/main_test.go @@ -7,11 +7,21 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime/debug" "strings" "testing" "time" ) +func withVersion(t *testing.T, value string) { + t.Helper() + previous := version + version = value + t.Cleanup(func() { + version = previous + }) +} + func TestParseArgsCooldownDays(t *testing.T) { opts, err := parseArgs([]string{"--cooldown-days", "7"}) if err != nil { @@ -36,16 +46,40 @@ func TestParseArgsRejectsOverflowingCooldownDays(t *testing.T) { } func TestRunVersion(t *testing.T) { + withVersion(t, "1.2.3") + var stdout bytes.Buffer exitCode := run([]string{"version"}, strings.NewReader(""), &stdout, &bytes.Buffer{}, http.DefaultClient, "") if exitCode != exitOK { t.Fatalf("expected exit 0, got %d", exitCode) } - if got := strings.TrimSpace(stdout.String()); got != version { + if got := strings.TrimSpace(stdout.String()); got != "1.2.3" { t.Fatalf("unexpected version output: %q", got) } } +func TestVersionFromBuildInfoUsesModuleVersion(t *testing.T) { + got := versionFromBuildInfo(&debug.BuildInfo{ + Main: debug.Module{Version: "v0.2.0"}, + }) + if got != "v0.2.0" { + t.Fatalf("unexpected version: %q", got) + } +} + +func TestVersionFromBuildInfoUsesVCSRevisionForDevelopmentBuild(t *testing.T) { + got := versionFromBuildInfo(&debug.BuildInfo{ + Main: debug.Module{Version: "(devel)"}, + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "0123456789abcdef"}, + {Key: "vcs.modified", Value: "true"}, + }, + }) + if got != "devel-0123456789ab+dirty" { + t.Fatalf("unexpected version: %q", got) + } +} + func TestRunHelpWithOtherFlags(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer