diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78fa6b7..689822f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -562,6 +562,96 @@ jobs: echo "### Published [$TAG](https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG)" >> "$GITHUB_STEP_SUMMARY" + # Ask proxy.golang.org for the tag just pushed, so the version is indexed + # deterministically rather than whenever the first person happens to fetch + # the module. Why that is worth a step, what pkg.go.dev does and does not + # show for this project, and how to do it by hand, all live in one place: + # docs/contribute/releasing.md. Do not restate any of it here. + # + # Last in the job, and warn-only, for one reason: by this point the tag is + # pushed and the release is created, so nothing here may fail the run or + # disturb the documented path that re-runs `publish` after a failure which + # already left a tag behind. + - name: Index the tag on pkg.go.dev + env: + TAG: ${{ needs.prepare.outputs.tag }} + run: | + set -eu + + # Warn-only means `set -e` must not get a chance to kill the step + # either, so every command below is guarded or explicitly ignored. + # stderr is deliberately kept: in a step whose only failure mode is + # silence, a toolchain error is the one line worth having. + mod="$(go list -m || true)" + + # Every module path this step cannot turn into a URL, each with its + # own reason — "unusable" is false of the uppercase one, where the + # path is fine and this step is the limitation. + case "$mod" in + "" | *[![:graph:]]*) + reason="go list -m gave no single-word module path" + ;; + *[[:upper:]]*) + # The path is all-lowercase github.com/behnam-rk/dezhban even + # though the repo is Behnam-RK/dezhban, so only a rename could + # produce a capital — and a silent 404 is worth naming. + reason="module path has capitals; proxy URLs escape those as !, which this step does not implement" + ;; + *.*/*) + # A proxy path needs a host in its first element. Without this, + # `go list -m` run outside a module prints the literal + # "command-line-arguments", which is a single lowercase word and + # clears every check above. + reason="" + ;; + *) + reason="not a proxy-fetchable module path (its first element names no host)" + ;; + esac + if [ -n "$reason" ]; then + # $mod stays out of the annotation — a newline in it truncates the + # annotation and loses the diagnostic. It goes to the log instead. + echo "go list -m gave: $mod" + echo "::warning title=pkg.go.dev::$reason — skipping" + exit 0 + fi + + info="https://proxy.golang.org/$mod/@v/$TAG.info" + + # Three attempts, and no more. They cover a transient network error + # or a 5xx on the way out of the runner — NOT a tag the proxy has yet + # to see. A miss is negative-cached, so the request that loses the + # propagation race is the one that installs the cached 404, and every + # later attempt in this job only re-reads it. Sitting here longer buys + # nothing, which is why the warning below points at a wait instead. + attempts=3 + attempt=1 + ok="" + while [ "$attempt" -le "$attempts" ]; do + if curl -fsS --max-time 30 -o /dev/null "$info"; then + ok=yes + break + fi + echo "proxy did not serve $TAG (attempt $attempt/$attempts)" + attempt=$((attempt + 1)) + if [ "$attempt" -le "$attempts" ]; then sleep 10; fi + done + + if [ -z "$ok" ]; then + echo "::warning title=pkg.go.dev::proxy.golang.org did not serve $mod@$TAG. Most likely it has negative-cached the miss, which expires on its own (up to ~30 minutes); after that, curl -fsS $info indexes the version. The release itself is fine." + exit 0 + fi + + # Ask for the page so pkg.go.dev builds it now instead of on its next + # index poll. The proxy fetch above is what actually publishes the + # version, so this one's status does not matter. + curl -fsS --max-time 30 -o /dev/null "https://pkg.go.dev/$mod@$TAG" || true + + # Only the proxy fetch was checked. pkg.go.dev often 404s or says + # "check back later" for a version it has not ingested yet, so do not + # claim the page is up — just link it. + echo "### Indexed on proxy.golang.org — [pkg.go.dev](https://pkg.go.dev/$mod@$TAG) follows shortly" >> "$GITHUB_STEP_SUMMARY" + # Make a dry run say so loudly, rather than looking like a release that silently # did nothing. dry-run-summary: diff --git a/docs/contribute/releasing.md b/docs/contribute/releasing.md index 2668f80..07b1278 100644 --- a/docs/contribute/releasing.md +++ b/docs/contribute/releasing.md @@ -22,7 +22,8 @@ prepare resolve the version; require it's ALREADY rolled; require CI green | -- writes nothing, anywhere -- build cross-compile all 5 CLI targets + 4 tarballs + 4 .deb/.rpm; | build the macOS .pkg; install it on a runner and uninstall it again -publish tag the tested commit, sign SHA256SUMS, publish the release +publish sign SHA256SUMS, tag the tested commit, publish the release, + index the tag on pkg.go.dev (warn-only — see below) ``` Nothing touches the repository until every artifact has been built and the @@ -38,7 +39,9 @@ the tag step reuses a tag that already points at the pinned commit, so the retry picks up where it stopped, and it still refuses a tag pointing anywhere else. A *fresh* dispatch is the wrong tool there — `resolve` sees the tag and stops, by design, so if you want a clean run instead of a re-run, delete the -stranded tag first (`git push --delete origin vX.Y.Z`). +stranded tag first (`git push --delete origin vX.Y.Z`) — after checking that the +proxy has not already served that version, which would make it unusable +([pkg.go.dev](#pkggodev)). `publish` also re-checks that `main` still points at the commit `prepare` pinned. If something merged mid-release, it stops rather than tag a tree that was never @@ -164,6 +167,61 @@ Each release carries: - `SHA256SUMS` — covering everything above - `SHA256SUMS.sig` — an **ed25519** signature over `SHA256SUMS` (see below) +## pkg.go.dev + +The tag is what publishes the module. pkg.go.dev is not a publish target — it +is a read-through cache over `proxy.golang.org`, and a version lands there only +once somebody asks the proxy for it. Left alone, that is whenever the first +person happens to fetch the module. + +So `publish`'s last step asks, for the tag it just pushed: + +```sh +curl -fsS "https://proxy.golang.org/github.com/behnam-rk/dezhban/@v/v0.13.0.info" +curl -fsS "https://pkg.go.dev/github.com/behnam-rk/dezhban@v0.13.0" +``` + +The first is what actually indexes the version; the second just makes +pkg.go.dev build the page now rather than on its next index poll. Run those two +by hand for any tag that was cut before this step existed, or that it missed. + +**A version the proxy has served can never be re-cut.** `sum.golang.org` records +its content hash permanently, and re-tagging `vX.Y.Z` at a different commit then +splits your users in two. On the default `GOPROXY=proxy.golang.org` they are +served the *cached original* and silently get the old tree — the worse half, because +the re-cut looks like it worked and shipped nothing. Fetching direct, they get +`checksum mismatch` / `SECURITY ERROR`. + +This step makes that certain and immediate for every release, but it was never +safe. A stranded tag that sat on origin for twenty minutes could have been +fetched by anyone in that window — one `go get …@latest` or a dependency bot is +enough. So the "delete the tag and re-dispatch" recovery above has a +precondition: ask first whether the version is already out. + +```sh +curl -fsS "https://proxy.golang.org/github.com/behnam-rk/dezhban/@v/v0.13.0.info" +``` + +Anything but a 404 means that version number is spent. Bump instead. + +The step **warns, it never fails**. By the time it runs, the tag is pushed and the +release is created — a cache warm-up that timed out is not a failed release, +and painting the run red would say it was. It also runs for rc tags: pkg.go.dev +files prereleases separately and never shows an rc as the latest version. + +**pkg.go.dev shows no documentation for dezhban, and that is expected.** It +renders docs only for licenses on its allow-list, and this project's is not on +it (see the LICENSE header for what it is and why). The module page still +carries the import path, the version list, `License: UNKNOWN`, and a +`Directories` listing — `cmd/dezhban`, the three `tools/`, and, behind *Show +Internal Directories*, the `internal/` tree. It has no doc area at all, there +being no package at the module root. Open one of those directories, say +`cmd/dezhban`, and where its docs would be the page says *Documentation not +displayed due to license restrictions*. Nothing is lost in practice: every +package here is under `internal/` or is a `main` package, so nothing is +importable from outside the module — there is no library surface for those docs +to have described. + ## Unsigned artifacts, signed checksums There is no Apple Developer certificate in this project ($99/yr, and this is