Skip to content

Refresh for v1.1.0: Go 1.26, token from gh, semver listing, install latest and uninstall - #5

Merged
jorgemoralespou merged 94 commits into
mainfrom
refresh-2026/integration
Oct 27, 2026
Merged

jorgemoralespou merged 94 commits into
mainfrom
refresh-2026/integration

Conversation

@jorgemoralespou

Copy link
Copy Markdown
Contributor

Summary

Refreshes educatesenv after 18 months, for v1.1.0: current toolchain and
dependencies, a GitHub token that is never stored or printed, release
listing by semver, install latest, uninstall, safer downloads, and a
release pipeline that cannot ship unsigned macOS binaries by accident.

What users see:

 educatesenv
   list-remote
-    --all                    # every release, pre-releases included, first 100 only
-    --recents                # the 10 most recently created
+    -n, --recent N           # the N newest stable releases by semver (default 10), with publish dates
+    --all                    # every stable release
+    --pre-releases           # add pre-releases; the old --all is --pre-releases --all
+    --by-date                # order by publish date instead of semver
+    --recents                # hidden, deprecated, changes nothing
   list                       # newest first by semver, develop on top
-  install <version>
+  install <version|latest>   # verified against checksums.txt, written to a temp file, stall timeout
+  uninstall <version>... [--force]
   use <version|develop>      # says what actually failed
   init [--download]          # --download installs the highest stable semver release
   config view                # shows where the token comes from, never the token

Where it lives:

pkg/
├── releases/    # new, pure: semver order, pre-release rule, selection, latest
├── token/       # new: EDUCATES_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN, gh auth token, config file (deprecated)
├── github/      # every page of releases, release assets, rate-limit and 401 messages
├── config/      # YAML without viper, read only from ~/.educatesenv, files created 0600
├── version/     # install, uninstall, active-binary handling, download timeouts
└── cmd/         # built by NewRootCommand, tested in-process
internal/
├── clitest/     # harness: temporary HOME, controlled PATH, fake gh
└── fakegithub/  # httptest GitHub: pages, assets, rate limits, cut and stalled downloads

Build and release:

  • Go 1.26.0 language version with the go1.26.10 toolchain, read by every
    workflow from go.mod. go-github v93, cobra 1.10.2, testify 1.12.1;
    viper and oauth2 removed.
  • Actions pinned by commit SHA to their latest release, kept current by
    Dependabot. golangci-lint pinned to v2.15.0 and run on pull requests,
    GoReleaser to v2.19.0.
  • Release assets are named educatesenv-<os>-<arch>, so the download
    command in the release notes header works.
  • MACOS_SIGNING switches macOS signing and notarization. The release
    workflow sets it to false for a manual run with skip-signing, or for
    every run while the MACOS_SIGNING repository variable is false, and
    annotates such runs. Otherwise the first step fails the run when any
    of the five MACOS_* secrets is empty, so GoReleaser can no longer sign
    without notarizing, or skip signing, and still succeed.
  • The release checkout has the full history for the changelog, and a
    rerun replaces a draft left by a failed run.

Evidence

  • Before: 7 test functions, none of them on the CLI or the GitHub
    client. After: 118, with go test ./... passing and golangci-lint
    v2.15.0 reporting 0 issues. A betterleaks scan of the new commits finds
    no leaks.

  • Behavior tests written first, against the old code:

    init --download, the 12 newest releases all release candidates
      before: fails, no stable release among the 10 newest
      after:  installs the highest stable release
    install, connection cut mid-transfer, then a retry
      before: partial binary left, the retry says "already installed"
      after:  nothing left behind, the retry installs
    install, server stops sending mid-transfer
      before: hangs
      after:  fails after 60 seconds without data, nothing left behind
    list-remote over 250 releases
      before: the first 100 only
      after:  every page, 100 per request
    development symlink into a sibling directory such as bin2
      before: taken for the bin directory
      after:  outside it
    binary built with go install at a tag
      before: reports "develop"
      after:  reports the tag
    
  • Signing switch, goreleaser build with every signing secret empty:

    Build MACOS_SIGNING Result
    snapshot unset, empty, false builds, signing skipped
    snapshot true tries to sign, fails on the empty certificate
    release unset, empty, true tries to sign, fails on the empty certificate
    release false builds, signing skipped
  • Not verified: the workflow expressions only run on GitHub, so the first
    release run is their end-to-end test.

Merge Danger

Door: two-way

Reverting the merge restores v1.0.0's behavior. The one-way step is
tagging and publishing v1.1.0, which stays manual.

Blast Radius: CLI users and the release pipeline

  • list-remote --all now means every stable release, and list-remote
    prints a table of the 10 newest by default. Scripts parsing the old
    output or relying on --all for pre-releases change.
  • A config.yaml in the working directory is no longer read.
  • A token in the config file still works but warns; init no longer
    writes one.
  • The next tag push needs all five MACOS_* secrets, or the
    MACOS_SIGNING repository variable set to false, or it fails before
    creating a release.

go.mod declares Go 1.26.0 as the language version and go1.26.10 as the
toolchain. The workflows read the version from go.mod, and the
golangci-lint config no longer names one.
github.New takes options, starting with WithBaseURL, so a test can point
the client at a fake server. New now returns an error for an invalid
base URL.
…1.12.1

go-github v93 builds its client from options and returns release tags
as plain strings.
The client passes the token through go-github's WithAuthToken, so
golang.org/x/oauth2 is no longer a dependency.
The version came from the build info's dependencies, where the main
module never appears, so binaries built with go install reported
develop. It now comes from the main module. A (devel) build still
reports develop, and a version set through ldflags still wins.
Each action is referenced by the commit its latest release tag points
at, with the tag in a trailing comment: checkout v7.0.1, setup-go
v7.0.0, upload-artifact v7.0.2, golangci-lint-action v9.3.0 and
goreleaser-action v7.3.0.
The release workflow's manual trigger gains a skip-signing input, off by
default, that runs GoReleaser with --skip=notarize, so a release can ship
unsigned when the Apple membership or certificate is unavailable.
Releases from tag pushes always sign.
The assets were named educatesenv-<os>-<arch>_<version>_<os>_<arch>, so
the download command in the release notes header returned 404. The
binary archive now names them educatesenv-<os>-<arch>, and the build
names the binary educatesenv so the two names do not collide.
NewRootCommand builds a fresh tree each time it is called, with flag values
captured per tree and the config, GitHub client and version manager loaded
by the root's PersistentPreRunE instead of package-level globals, init()
registration and cobra.OnInitialize. WithGitHubBaseURL points the GitHub
client at another API base URL; it defaults to github.com and is not exposed
to users. main builds the tree and executes it.
internal/fakegithub serves paginated release lists, releases by tag and
asset downloads from releases each test sets up, records the requests it
receives, and lets a test register handlers that take precedence over the
defaults. internal/clitest builds a fresh command tree per run with a
temporary home and working directory, a PATH that holds only an optional
fake gh script, the EDUCATES_* and gh variables removed, and the GitHub
client pointed at the fake server, and captures stdout, stderr and the
returned error.
list-remote, list, list-local, init, config init, config view and version
now fail with cobra's unknown command error when given an argument, before
reading the config or calling GitHub. version used ExactArgs(0) and now
reports the same error as the others.
Load used viper's package-level instance, so config search paths piled up
across loads and values from an earlier load survived when the config file
was later missing. Each Load now starts from a new instance.
init, list, list-remote, use, the development-mode warning and the version
manager's install progress printed to the process's stdout. They now write
to the command's OutOrStdout, so the same text still reaches stdout and a
caller that sets its own writers receives it. version.New takes the writer
the manager reports to.

The version manager tests move to the CLI harness: development-mode
cleanup, use, and installing a named version from the fake GitHub server,
which replaces the TODO about mocking the GitHub client.
… writer

New and Load each printed the warning to the process's stdout, so a command
without HOME printed it twice. Load now writes it once to the writer it is
given, which the root command sets to cobra's stdout.
TestLoadWithEnvVars read the developer's own ~/.educatesenv/config.yaml,
since the config is also searched for in the home directory. Each test now
sets HOME to a temporary directory.
errcheck in golangci-lint v2.15.0 reports unchecked fmt.Fprintf and
fmt.Fprintln calls on an io.Writer. A failed write to the command's own
output leaves nothing to report to, so each call discards the result with
_, _ =. The fake server test takes the base URL's address instead of
github.Ptr, which govet's inline analyzer reports.
… with the active binary

list, use, install and init remove a development symlink left behind when development mode is disabled. version, config and list-remote no longer touch the educates symlink.
… the bin directory

The check used a string prefix, so a target in a sibling directory such as bin2 counted as inside the bin directory. It now compares the cleaned directory paths.
…sh time

A new pure package holds the release type and the selection rules: a
release is a pre-release when its tag has a semver pre-release part or
GitHub flags it, drafts and tags that are not full semver never take
part, and selection orders by semver or publish time, applies a limit
and reports the size of the candidate set.
CutNextDownload makes the next download of an asset announce its full length, send half of it and drop the connection, and serves later downloads whole, so that tests can interrupt a download and retry it.
An interrupted download left a partial binary that a retry then reported as already installed. The binary now downloads into a temporary file in the bin directory, is made executable and only then renamed into place, and the temporary file is removed when any step fails.
use said a version was not installed whatever went wrong. It now says so only when the binary is missing, and otherwise passes the cause through, such as a bin directory it cannot write.
Cover the defaults, case-insensitive keys, each environment override,
empty environment variables, the boolean forms and the token sources
through the commands, and drop the package tests that read the working
directory.
…ected

A rate limit gives the time it resets, and without a token points at
GH_TOKEN or gh auth login. A 401 names the source of the rejected
token. The 404 messages for a missing repository or version are kept.
Document only the commands and flags that exist, with one installation
section and one usage section: list-remote's selection flags and output,
install latest and checksum verification, uninstall, the config file
and its environment overrides, and development mode.

Document the token sources in the order they are checked, gh support,
and the deprecated config-file token. Match the Go prerequisite and the
golangci-lint version to go.mod and CI, and list the Makefile's targets.

Add a release section on the signing certificate, notarization, drafts
and releasing unsigned with skip-signing, and a macOS note on clearing
the quarantine attribute after a browser download.
GoReleaser enabled signing only when MACOS_SIGN_P12 was set, and a
missing secret expands to an empty value in Actions, so a tag push
without the certificate skipped signing and released unsigned binaries.
A release now always signs, so it fails when the certificate is empty or
missing. Only --skip=notarize, which the manual trigger's skip-signing
input passes, releases unsigned. Snapshot builds still sign only when
MACOS_SIGN_P12 is set.
config had no run function, so cobra printed its help for any arguments
and exited 0. config now takes no arguments and prints its help when run
alone, so 'educatesenv config stray' fails like the other commands that
take no arguments.
educatesenv 1.0 read config values through viper's GetBool, which read a
value such as 'enabled: yes' or EDUCATES_DEVELOPMENT_ENABLED=yes as
false. The YAML loader made such a value an error, so every command
failed, version included. A boolean is now read as GetBool read it, and
one in a form it did not accept prints a warning on stderr that names
the key and file, or the variable. YAML that does not parse is still an
error.
use latest failed with a hint to run 'educatesenv install latest', after
which use latest still failed, since install resolves latest to a
version. It now says that use takes an installed version and suggests
'educatesenv install latest --use', which installs the latest stable
release and switches to it.
use's usage line showed its argument as optional, [version|develop],
though use requires it, and its not-installed message quoted the install
command in backticks where every other message uses single quotes.
Paths.Dir and Paths.File sat beside BinDir without saying which
directory and file they were.
The bare source, labels and environment become tokenSource,
sourceLabels and tokenVariables.
…tant

The config-file token warning and the rate-limit error each spelled out
"Set GH_TOKEN or run 'gh auth login'".
The bare Options and Order did not say what they choose or order.
The helper only adds the v prefix that golang.org/x/mod/semver needs,
which the name version did not say.
list and uninstall each scanned the bin directory, and found the active
version in two ways: list compared the symlink's target with the
development binary's path as a string, and uninstall compared files. The
symlink that 'use develop' writes is relative, so list never marked
develop active after it. The version manager's Installed now lists the
installed versions and finds the active one, by comparing files, for
both commands.
…once

The literals "develop" and "educates" and the binary prefix were
spelled out across list, use, uninstall and the version manager. The
version manager now has a Develop constant, the name of the educates
symlink, and one place each that builds the symlink's path and a
version's binary path from platform.BinaryPrefix.
StallNextDownload sends half of an asset and then nothing more until the
client gives up. TrickleDownloads sends an asset in chunks spaced apart.
CutNextDownload shares the part that announces the full length and sends
the first half.
Each read of a download's body waits at most 60 seconds for data, and the
request is cancelled when it waits longer. The error says the download
stalled and for how long. Nothing caps the whole transfer, so a slow
download that keeps making progress finishes. cmd.WithDownloadIdleTimeout
shortens the wait for tests.
GoReleaser's changelog needs the previous tag, which a shallow checkout
of one commit does not have.
GoReleaser signs and then skips notarization, still succeeding, when a
notary secret is empty. The release workflow now checks the five macOS
secrets first and fails, naming the missing ones, unless a manual run
sets skip-signing.
A run that fails while uploading leaves a draft release, and GitHub's
lookup by tag never finds drafts, so a rerun created a second draft for
the same tag. GoReleaser now deletes a draft with the same name before
creating the release.
Signing and notarization were enabled by whether MACOS_SIGN_P12 was set,
so a local build could not turn them on or off on demand, and goreleaser
build cannot skip them. MACOS_SIGNING now switches them for release and
build alike: a release signs unless it is false, and a snapshot signs
only when it is true.
The workflow sets MACOS_SIGNING once for the job, false for a manual run
with skip-signing and true otherwise. The secrets check and
.goreleaser.yml both read it, replacing the --skip=notarize argument.
While the Apple membership or certificate is unavailable, setting the
MACOS_SIGNING repository variable to false makes every run, tag pushes
included, release without signing or notarizing the macOS binaries. Each
such run adds a warning to its summary.
@jorgemoralespou
jorgemoralespou merged commit 67c2be6 into main Oct 27, 2026
4 checks passed
@jorgemoralespou
jorgemoralespou deleted the refresh-2026/integration branch October 27, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant