From bcf870517d93086f56d8403bceb81ef844d003a4 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 23:46:52 -0300 Subject: [PATCH 1/3] ci: allow releasing the Go module on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Go module was the only package that could not be published from the Actions tab: tag-go-module ran on a release event alone, and a release event also uploads to PyPI and npm. That was the shape the Go port needed the day it landed — the other two were already at 1.6.0 and had nothing new to publish — and it had to be done by hand. `target` now takes `go`, and `both` becomes `all` since there are three packages to choose from. Run from a release the module tag still points at the release's tag; run by hand it points at the dispatched commit, which is what releasing the module alone means. Two guards, because a published Go version is immutable in the proxy and cannot be replaced: the job refuses a version that is already tagged, and refuses a commit that carries no go/go.mod, which would otherwise cache an empty module forever. `dry_run` reports what it would tag. --- .github/workflows/publish.yml | 54 +++++++++++++++++++++++++++-------- CONTRIBUTING.md | 12 ++++++++ 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 677932d..df601bf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,8 +11,8 @@ on: target: description: 'Which package(s) to publish' type: choice - options: [both, pypi, npm] - default: both + options: [all, pypi, npm, go] + default: all dry_run: description: 'Build and validate only, without uploading' type: boolean @@ -24,6 +24,9 @@ permissions: jobs: verify-versions: runs-on: ubuntu-latest + outputs: + # Read by tag-go-module when there is no release tag to take it from. + go: ${{ steps.versions.outputs.go }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -39,8 +42,8 @@ jobs: echo "Python: $py_version | npm: $npm_version | Go: $go_version" - name: Check versions are in sync - # The Go module is published by the release tag itself, so it has no - # upload job below — only this check keeps its version honest. + # The Go module has no registry upload below: the tag is its release, + # and this check is what keeps its version honest. run: | if [ "${{ steps.versions.outputs.python }}" != "${{ steps.versions.outputs.npm }}" ] \ || [ "${{ steps.versions.outputs.python }}" != "${{ steps.versions.outputs.go }}" ]; then @@ -60,7 +63,7 @@ jobs: tag-go-module: needs: verify-versions - if: github.event_name == 'release' + if: github.event_name == 'release' || inputs.target == 'all' || inputs.target == 'go' runs-on: ubuntu-latest permissions: contents: write # Required to push the module tag @@ -71,23 +74,50 @@ jobs: # A module in a subdirectory is released by a tag carrying that prefix, so # `go get .../go@latest` sees nothing without this. + # + # Run from a release, the tag points at the release's own tag. Run by hand + # it points at the dispatched commit, which is what releasing the Go + # module on its own means: the other two packages did not change, so there + # is no release tag to hang it from. - name: Tag the Go module env: - TAG: ${{ github.event.release.tag_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + DECLARED_VERSION: ${{ needs.verify-versions.outputs.go }} + DRY_RUN: ${{ github.event_name != 'release' && inputs.dry_run }} run: | - version="${TAG#v}" + if [ "${{ github.event_name }}" = "release" ]; then + version="${RELEASE_TAG#v}" + target="${RELEASE_TAG}" + else + version="${DECLARED_VERSION}" + target="${GITHUB_SHA}" + fi + module_tag="go/v${version}" + if git rev-parse -q --verify "refs/tags/${module_tag}" >/dev/null; then - echo "${module_tag} already exists" + echo "::error::${module_tag} already exists — a published version is immutable, bump go/flightradarapi/doc.go" + exit 1 + fi + + # The tag has to carry the module, or the proxy caches an empty one. + if ! git cat-file -e "${target}:go/go.mod" 2>/dev/null; then + echo "::error::${target} carries no go/go.mod" + exit 1 + fi + + if [ "${DRY_RUN}" = "true" ]; then + echo "Would tag ${module_tag} at ${target}" exit 0 fi - git tag "${module_tag}" "${TAG}" + + git tag "${module_tag}" "${target}" git push origin "${module_tag}" - echo "Pushed ${module_tag}" + echo "Pushed ${module_tag} at ${target}" publish-pypi: needs: verify-versions - if: github.event_name == 'release' || inputs.target == 'both' || inputs.target == 'pypi' + if: github.event_name == 'release' || inputs.target == 'all' || inputs.target == 'pypi' runs-on: ubuntu-latest environment: pypi permissions: @@ -122,7 +152,7 @@ jobs: publish-npm: needs: verify-versions - if: github.event_name == 'release' || inputs.target == 'both' || inputs.target == 'npm' + if: github.event_name == 'release' || inputs.target == 'all' || inputs.target == 'npm' runs-on: ubuntu-latest environment: npm permissions: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35709b6..c0ccc46 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,6 +77,18 @@ so the `tag-go-module` job of `publish.yml` pushes `go/v1.6.0` alongside the release tag. Nothing to do by hand; if that job is skipped, `go get .../go@latest` finds no release and callers fall back to a pseudo-version. +### Releasing one package on its own + +`publish.yml` also runs from the Actions tab, where `target` picks what goes +out: `pypi`, `npm`, `go`, or `all`. Use `go` when the Go module changed and the +other two did not — it tags the dispatched commit, since there is no release tag +to hang the module tag from. `dry_run` is on by default and reports what would +be tagged without pushing. + +A published Go version is immutable in the module proxy, so the job refuses to +overwrite one: bump `Version` in `go/flightradarapi/doc.go` first. Note that the +version check above still requires all three ports to declare the same version. + ## Reporting bugs and asking questions - Bugs: open a GitHub issue with the bug report template. From 002afe9f599372a73bd5312c0bd03ec2b3313e1b Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 24 Aug 2026 23:55:58 -0300 Subject: [PATCH 2/3] fix: keep the tag step idempotent, and fail closed on the push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the previous commit found that refusing an existing tag broke the path this very change adds: dispatch the module tag at 1.7.0, then publish the 1.7.0 release, and the release workflow goes red at a step that has nothing left to do — with PyPI and npm already published, and advice to bump a version that is correct. Re-running a release after a transient upload failure hit the same wall. The check now compares commits rather than names: the same commit is a no-op, a different one is the immutable-version error it was meant to be. Two more, both about a push that cannot be taken back. A dispatch can be started from any branch, so the commit now has to be on main. And the dry-run guard read "true means dry run", which pushed for any other value — including the empty string a trigger with no inputs would produce; it now pushes only when told "false" in so many words. The section in CONTRIBUTING promised a flow the version gate forbids: bumping only the Go version fails verify-versions, so target: go publishes the version the repository already declares, which is what it is for. --- .github/workflows/publish.yml | 39 ++++++++++++++++++++++++++++------- CONTRIBUTING.md | 23 ++++++++++++++------- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index df601bf..6baded0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -94,26 +94,49 @@ jobs: fi module_tag="go/v${version}" - - if git rev-parse -q --verify "refs/tags/${module_tag}" >/dev/null; then - echo "::error::${module_tag} already exists — a published version is immutable, bump go/flightradarapi/doc.go" + commit=$(git rev-parse "${target}^{commit}") + + # Idempotent on the commit, not on the name: re-running a release, or + # publishing one after the module was tagged by hand, must not fail. + if existing=$(git rev-parse -q --verify "refs/tags/${module_tag}^{commit}"); then + if [ "${existing}" = "${commit}" ]; then + echo "${module_tag} already points at ${commit}" + exit 0 + fi + echo "::error::${module_tag} exists at ${existing}, not ${commit} — a published version is immutable, bump go/flightradarapi/doc.go" exit 1 fi # The tag has to carry the module, or the proxy caches an empty one. - if ! git cat-file -e "${target}:go/go.mod" 2>/dev/null; then + if ! git cat-file -e "${commit}:go/go.mod" 2>/dev/null; then echo "::error::${target} carries no go/go.mod" exit 1 fi - if [ "${DRY_RUN}" = "true" ]; then - echo "Would tag ${module_tag} at ${target}" + # A dispatch can be started from any branch, and the tag it leaves + # behind cannot be taken back. + if [ "${{ github.event_name }}" != "release" ]; then + git fetch -q origin main || { + echo "::error::could not read main to check the commit against it" + exit 1 + } + if ! git merge-base --is-ancestor "${commit}" origin/main; then + echo "::error::${commit} is not on main" + exit 1 + fi + fi + + # Pushes only when told to in so many words: anything else — a + # trigger added to `on:` that carries no inputs, say — is a dry run, + # because this push is the one thing here that cannot be undone. + if [ "${DRY_RUN}" != "false" ]; then + echo "Would tag ${module_tag} at ${commit}" exit 0 fi - git tag "${module_tag}" "${target}" + git tag "${module_tag}" "${commit}" git push origin "${module_tag}" - echo "Pushed ${module_tag} at ${target}" + echo "Pushed ${module_tag} at ${commit}" publish-pypi: needs: verify-versions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0ccc46..afe026b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,14 +80,21 @@ release tag. Nothing to do by hand; if that job is skipped, `go get ### Releasing one package on its own `publish.yml` also runs from the Actions tab, where `target` picks what goes -out: `pypi`, `npm`, `go`, or `all`. Use `go` when the Go module changed and the -other two did not — it tags the dispatched commit, since there is no release tag -to hang the module tag from. `dry_run` is on by default and reports what would -be tagged without pushing. - -A published Go version is immutable in the module proxy, so the job refuses to -overwrite one: bump `Version` in `go/flightradarapi/doc.go` first. Note that the -version check above still requires all three ports to declare the same version. +out: `pypi`, `npm`, `go`, or `all`. `dry_run` is on by default and reports what +would happen without publishing anything. + +`target: go` tags the dispatched commit, which must be on `main`. It exists for +when the module needs the tag that a release did not leave behind — the Go SDK +arriving after the other two had already shipped that version, or a release +whose tagging step never ran. The version comes from the three declared +versions, which `verify-versions` still requires to agree, so this publishes the +version the repository already declares rather than a new one. + +Tagging a version the Go module has already published is refused: a version is +immutable in the module proxy, and the only way past it is a version bump — +which means bumping all three ports and cutting a normal release. Re-tagging the +same commit is not refused, so re-running a release, or publishing one after the +module was tagged by hand, does what you would expect and nothing else. ## Reporting bugs and asking questions From 20d771a3546bb4c4056b6a03910fedbbab83fb10 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Tue, 25 Aug 2026 00:05:35 -0300 Subject: [PATCH 3/3] fix: let a dry run rehearse, and tag after the registries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guards were hard failures even in a dry run, so the only way to try a change to a dispatch — running it from its own branch — died at the first one. They now report what they would refuse and leave the job green: a rehearsal has nothing to undo. The commit is resolved through the same path, rather than letting the step die on a raw git message with no annotation, and the ancestry check tells apart "not an ancestor" from git failing, so an infrastructure problem stops reading as an accusation about the commit. The tag job now runs after the two registry jobs. It is the only step here that cannot be taken back, and a proxy that will serve a version forever should not outlive an upload that was rejected. Skipped dependencies would otherwise take the job with them on a single-target dispatch, which the cancelled/failure guard prevents. --- .github/workflows/publish.yml | 67 +++++++++++++++++++++++------------ CONTRIBUTING.md | 3 +- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6baded0..f866162 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -62,8 +62,15 @@ jobs: fi tag-go-module: - needs: verify-versions - if: github.event_name == 'release' || inputs.target == 'all' || inputs.target == 'go' + needs: [verify-versions, publish-pypi, publish-npm] + # Last on purpose: the tag is the only step here that cannot be undone, and + # a registry that rejected the version must not be outlived by a module the + # proxy serves forever. A single-target dispatch skips the two publish jobs, + # and a skipped dependency would take this job with it — which is what the + # cancelled/failure pair is there to prevent. + if: >- + !cancelled() && !failure() + && (github.event_name == 'release' || inputs.target == 'all' || inputs.target == 'go') runs-on: ubuntu-latest permissions: contents: write # Required to push the module tag @@ -81,11 +88,25 @@ jobs: # is no release tag to hang it from. - name: Tag the Go module env: + EVENT_NAME: ${{ github.event_name }} RELEASE_TAG: ${{ github.event.release.tag_name }} DECLARED_VERSION: ${{ needs.verify-versions.outputs.go }} DRY_RUN: ${{ github.event_name != 'release' && inputs.dry_run }} run: | - if [ "${{ github.event_name }}" = "release" ]; then + # Reports in a dry run and refuses for real: a rehearsal has nothing + # to undo, so it must say what would happen rather than fail. Without + # this, the only way to try a dispatch change — from its own branch — + # dies at the first guard. + refuse() { + if [ "${DRY_RUN}" != "false" ]; then + echo "Would refuse: $1" + exit 0 + fi + echo "::error::$1" + exit 1 + } + + if [ "${EVENT_NAME}" = "release" ]; then version="${RELEASE_TAG#v}" target="${RELEASE_TAG}" else @@ -94,7 +115,9 @@ jobs: fi module_tag="go/v${version}" - commit=$(git rev-parse "${target}^{commit}") + + commit=$(git rev-parse -q --verify "${target}^{commit}") || + refuse "${target} does not resolve to a commit in this checkout" # Idempotent on the commit, not on the name: re-running a release, or # publishing one after the module was tagged by hand, must not fail. @@ -103,32 +126,32 @@ jobs: echo "${module_tag} already points at ${commit}" exit 0 fi - echo "::error::${module_tag} exists at ${existing}, not ${commit} — a published version is immutable, bump go/flightradarapi/doc.go" - exit 1 + refuse "${module_tag} exists at ${existing}, not ${commit} — a published version is immutable, bump go/flightradarapi/doc.go" fi # The tag has to carry the module, or the proxy caches an empty one. - if ! git cat-file -e "${commit}:go/go.mod" 2>/dev/null; then - echo "::error::${target} carries no go/go.mod" - exit 1 - fi + git cat-file -e "${commit}:go/go.mod" 2>/dev/null || + refuse "${target} carries no go/go.mod" # A dispatch can be started from any branch, and the tag it leaves # behind cannot be taken back. - if [ "${{ github.event_name }}" != "release" ]; then - git fetch -q origin main || { - echo "::error::could not read main to check the commit against it" - exit 1 - } - if ! git merge-base --is-ancestor "${commit}" origin/main; then - echo "::error::${commit} is not on main" - exit 1 - fi + if [ "${EVENT_NAME}" != "release" ]; then + git fetch -q origin main || + refuse "could not read main to check the commit against it" + + # Told apart deliberately: 1 means "not an ancestor", anything else + # is git failing, which must not read as an accusation about the + # commit. FETCH_HEAD, because the line above is what just wrote it. + status=0 + git merge-base --is-ancestor "${commit}" FETCH_HEAD || status=$? + + case "${status}" in + 0) ;; + 1) refuse "${commit} is not on main" ;; + *) refuse "could not compare ${commit} against main (git exited ${status})" ;; + esac fi - # Pushes only when told to in so many words: anything else — a - # trigger added to `on:` that carries no inputs, say — is a dry run, - # because this push is the one thing here that cannot be undone. if [ "${DRY_RUN}" != "false" ]; then echo "Would tag ${module_tag} at ${commit}" exit 0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index afe026b..c061eb2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,7 +81,8 @@ release tag. Nothing to do by hand; if that job is skipped, `go get `publish.yml` also runs from the Actions tab, where `target` picks what goes out: `pypi`, `npm`, `go`, or `all`. `dry_run` is on by default and reports what -would happen without publishing anything. +would happen without publishing anything — including what it would refuse, so a +rehearsal from a branch tells you the outcome instead of failing. `target: go` tags the dispatched commit, which must be on `main`. It exists for when the module needs the tag that a release did not leave behind — the Go SDK