From e33b651ab43f2c81020fe0727eb5861dc0765957 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 14 Aug 2026 12:49:17 -0700 Subject: [PATCH 1/4] fix: Package-and-Upload workflow attaches binaries to UI-created releases Two bugs found while investigating why v2.0.21 shipped without executables attached: 1. The "Upload binaries to release" step was gated on `github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')`. Releases created via the GitHub UI create a tag but do not fire the push:tags: event that this workflow depends on, so v2.0.21 (and v2.0.19) ended up as releases with no assets. The only successful run for v2.0.21 was a workflow_dispatch afterward that built the binaries as artifacts but skipped the upload-to-release step. 2. Both macOS build jobs used `name: tabcmd-macos` on `actions/upload-artifact`, so the two same-named artifacts collided in the artifact store and downloads clobbered each other. Fixes: - New `release_tag` workflow_dispatch input. Set it when dispatching from a branch to attach binaries to a UI-created release. Falls back to `github.ref_name` when the workflow runs on a tag ref (push or dispatch). - Fix mac artifact collision: use `matrix.UPLOAD_FILE_NAME` (unique per platform) as the artifact name instead of `tabcmd-${{ matrix.TARGET }}`. - Split the upload into a separate `upload_to_release` job that depends on `buildexe`, gated on `environment: release`. The `release` environment needs to be created in the repo's Settings -> Environments with required-reviewer protection (mirroring the existing `pypi` environment). Anyone with dispatch access can trigger a build, but only an approved reviewer can attach binaries to a public release. - Upload gate: `if: startsWith(github.ref, 'refs/tags/') || inputs.release_tag != ''` so both the push:tags path and the workflow_dispatch path work. Live-verified by using `gh release upload` today to fix v2.0.21 retroactively with the artifacts from the last workflow_dispatch run. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/package.yml | 82 ++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 9413ed6a..058e2692 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -13,11 +13,21 @@ on: tags: - '*' workflow_dispatch: + inputs: + release_tag: + description: > + Tag of an existing release to attach build artifacts to. Leave blank + when dispatching on a tag ref (github.ref_name is used). Set this + when dispatching from a branch to attach binaries to a release + created via the GitHub UI (which does not fire the push:tags: event + that would trigger this workflow automatically). + required: false + default: '' jobs: buildexe: - name: Build executables and upload them to the existing release + name: Build executables runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -97,18 +107,76 @@ jobs: tar -cvf ${{ matrix.UPLOAD_FILE_NAME }} ${{ matrix.OUT_FILE_NAME }} + # UPLOAD_FILE_NAME distinguishes the two macOS artifacts (x86 vs arm64); + # the shared artifact name `tabcmd-macos` would otherwise collide and each + # upload would clobber the other in the artifact store. - name: Upload build artifact for ${{ matrix.TARGET }} uses: actions/upload-artifact@v7 with: - name: tabcmd-${{ matrix.TARGET }} + name: ${{ matrix.UPLOAD_FILE_NAME }} path: ./dist/${{ matrix.TARGET }}/${{ matrix.UPLOAD_FILE_NAME }} - - name: Upload binaries to release for ${{ matrix.TARGET }} - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + # Attach the built binaries to the target GitHub release. Split into its own + # job so we can gate it behind the `release` environment: a required-reviewer + # protection on that environment means anyone with dispatch access can + # trigger a build, but only an approved reviewer can actually attach binaries + # to a public release. On push:tags this still runs but the approval step + # will pause the workflow until a reviewer clicks Approve. + # + # `needs: buildexe` waits for ALL matrix legs to succeed. If any leg fails + # this job is skipped (default behavior with no `if: always()`), so a partial + # release upload where e.g. macOS is missing is never possible. + upload_to_release: + name: Attach build artifacts to release + needs: buildexe + if: startsWith(github.ref, 'refs/tags/') || inputs.release_tag != '' + runs-on: ubuntu-latest + environment: release + steps: + # upload-artifact@v7 pairs with download-artifact@v8: the two action majors + # don't move in lockstep. v8 of download-artifact adds hash-mismatch-errors + # and direct-download support; there is no v8 of upload-artifact yet. + - name: Download all build artifacts + uses: actions/download-artifact@v8 + with: + path: artifacts/ + + # Upload runs on both push:tags and workflow_dispatch. For push:tags, + # github.ref_name is the tag. For workflow_dispatch, use the release_tag + # input if set (release created via GitHub UI), else fall back to + # github.ref_name (workflow dispatched on a tag ref). + - name: Upload tabcmd.exe (Windows) to release + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + asset_name: tabcmd.exe + file: artifacts/tabcmd.exe/tabcmd.exe + tag: ${{ inputs.release_tag || github.ref_name }} + overwrite: true + + - name: Upload tabcmd (Ubuntu) to release + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + asset_name: tabcmd + file: artifacts/tabcmd/tabcmd + tag: ${{ inputs.release_tag || github.ref_name }} + overwrite: true + + - name: Upload tabcmd-x86.app.tar (macOS x86) to release + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + asset_name: tabcmd-x86.app.tar + file: artifacts/tabcmd-x86.app.tar/tabcmd-x86.app.tar + tag: ${{ inputs.release_tag || github.ref_name }} + overwrite: true + + - name: Upload tabcmd_arm64.app.tar (macOS ARM64) to release uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} - asset_name: ${{ matrix.UPLOAD_FILE_NAME }} - file: ./dist/${{ matrix.TARGET }}/${{ matrix.UPLOAD_FILE_NAME }} - tag: ${{ github.ref_name }} + asset_name: tabcmd_arm64.app.tar + file: artifacts/tabcmd_arm64.app.tar/tabcmd_arm64.app.tar + tag: ${{ inputs.release_tag || github.ref_name }} overwrite: true From 04e564ca578895fbccb44ab3644846a203d4b58c Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Thu, 10 Sep 2026 12:56:13 -0700 Subject: [PATCH 2/4] Harden Package-and-Upload workflow per review - Switch to release: types: [published] as the trigger (matches publish-pypi.yml pattern). Removes the manual-dispatch-with-input footgun; release payload provides the tag directly. - Add explicit permissions: contents: write on the upload job so it survives future org-default hardening. - Add concurrency group keyed on the release tag; prevents overlapping runs from stomping each other's approved uploads. - Pin download-artifact back to v7 to match upload-artifact@v7 until v7/v8 interop is verified. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/package.yml | 50 +++++++++++++---------------------- 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 058e2692..87c453a4 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -9,20 +9,12 @@ name: Package-and-Upload # https://anshumanfauzdar.medium.com/using-github-actions-to-bundle-python-application-into-a-single-package-and-automatic-release-834bd42e0670 on: - push: - tags: - - '*' - workflow_dispatch: - inputs: - release_tag: - description: > - Tag of an existing release to attach build artifacts to. Leave blank - when dispatching on a tag ref (github.ref_name is used). Set this - when dispatching from a branch to attach binaries to a release - created via the GitHub UI (which does not fire the push:tags: event - that would trigger this workflow automatically). - required: false - default: '' + release: + types: [published] + +concurrency: + group: package-${{ github.event.release.tag_name }} + cancel-in-progress: false jobs: @@ -118,10 +110,9 @@ jobs: # Attach the built binaries to the target GitHub release. Split into its own # job so we can gate it behind the `release` environment: a required-reviewer - # protection on that environment means anyone with dispatch access can - # trigger a build, but only an approved reviewer can actually attach binaries - # to a public release. On push:tags this still runs but the approval step - # will pause the workflow until a reviewer clicks Approve. + # protection on that environment means publishing a release triggers a build, + # but only an approved reviewer can actually attach binaries to that release. + # The workflow pauses at this job until a reviewer clicks Approve. # # `needs: buildexe` waits for ALL matrix legs to succeed. If any leg fails # this job is skipped (default behavior with no `if: always()`), so a partial @@ -129,29 +120,26 @@ jobs: upload_to_release: name: Attach build artifacts to release needs: buildexe - if: startsWith(github.ref, 'refs/tags/') || inputs.release_tag != '' runs-on: ubuntu-latest environment: release + permissions: + contents: write steps: - # upload-artifact@v7 pairs with download-artifact@v8: the two action majors - # don't move in lockstep. v8 of download-artifact adds hash-mismatch-errors - # and direct-download support; there is no v8 of upload-artifact yet. + # v7 to match upload-artifact@v7; bump both together - name: Download all build artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@v7 with: path: artifacts/ - # Upload runs on both push:tags and workflow_dispatch. For push:tags, - # github.ref_name is the tag. For workflow_dispatch, use the release_tag - # input if set (release created via GitHub UI), else fall back to - # github.ref_name (workflow dispatched on a tag ref). + # The `release: published` trigger provides the tag directly via + # github.event.release.tag_name - no fallback needed. - name: Upload tabcmd.exe (Windows) to release uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} asset_name: tabcmd.exe file: artifacts/tabcmd.exe/tabcmd.exe - tag: ${{ inputs.release_tag || github.ref_name }} + tag: ${{ github.event.release.tag_name }} overwrite: true - name: Upload tabcmd (Ubuntu) to release @@ -160,7 +148,7 @@ jobs: repo_token: ${{ secrets.GITHUB_TOKEN }} asset_name: tabcmd file: artifacts/tabcmd/tabcmd - tag: ${{ inputs.release_tag || github.ref_name }} + tag: ${{ github.event.release.tag_name }} overwrite: true - name: Upload tabcmd-x86.app.tar (macOS x86) to release @@ -169,7 +157,7 @@ jobs: repo_token: ${{ secrets.GITHUB_TOKEN }} asset_name: tabcmd-x86.app.tar file: artifacts/tabcmd-x86.app.tar/tabcmd-x86.app.tar - tag: ${{ inputs.release_tag || github.ref_name }} + tag: ${{ github.event.release.tag_name }} overwrite: true - name: Upload tabcmd_arm64.app.tar (macOS ARM64) to release @@ -178,5 +166,5 @@ jobs: repo_token: ${{ secrets.GITHUB_TOKEN }} asset_name: tabcmd_arm64.app.tar file: artifacts/tabcmd_arm64.app.tar/tabcmd_arm64.app.tar - tag: ${{ inputs.release_tag || github.ref_name }} + tag: ${{ github.event.release.tag_name }} overwrite: true From 499068491823d11441e910e07890a0e4d97be5f3 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 18 Sep 2026 18:09:46 -0700 Subject: [PATCH 3/4] ci(package): restore workflow_dispatch + resolve release tag across event paths Copilot flagged that removing workflow_dispatch broke the release-on-merge.yml caller and the documented manual re-attach path, and that github.event.release.tag_name is empty for anything other than release:published events. Restores workflow_dispatch alongside release:published, adds a "Resolve release tag" step that falls back to github.ref_name, and switches all four uploaders to the resolved output. --- .github/workflows/package.yml | 39 ++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 87c453a4..7251e3e5 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -11,9 +11,13 @@ name: Package-and-Upload on: release: types: [published] + # Manual repair path + release-on-merge.yml's `gh workflow run package.yml + # --ref ` invocation. The tag is resolved from github.ref_name when the + # release event isn't the trigger. + workflow_dispatch: concurrency: - group: package-${{ github.event.release.tag_name }} + group: package-${{ github.event.release.tag_name || github.ref_name }} cancel-in-progress: false jobs: @@ -131,15 +135,36 @@ jobs: with: path: artifacts/ - # The `release: published` trigger provides the tag directly via - # github.event.release.tag_name - no fallback needed. + # Resolve the release tag from whichever event triggered us: `release: + # published` populates github.event.release.tag_name; workflow_dispatch + # (invoked with `--ref `) leaves that empty and github.ref_name + # carries the tag instead. Fail fast if neither is set so we never + # attempt an upload against an empty tag. + - name: Resolve release tag + id: tag + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + REF_NAME: ${{ github.ref_name }} + EVENT_NAME: ${{ github.event_name }} + run: | + TAG="$RELEASE_TAG" + if [ -z "$TAG" ]; then + TAG="$REF_NAME" + fi + if [ -z "$TAG" ]; then + echo "::error::No release tag resolvable (event=$EVENT_NAME, release.tag_name empty, ref_name empty)" + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Resolved release tag: $TAG" + - name: Upload tabcmd.exe (Windows) to release uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} asset_name: tabcmd.exe file: artifacts/tabcmd.exe/tabcmd.exe - tag: ${{ github.event.release.tag_name }} + tag: ${{ steps.tag.outputs.tag }} overwrite: true - name: Upload tabcmd (Ubuntu) to release @@ -148,7 +173,7 @@ jobs: repo_token: ${{ secrets.GITHUB_TOKEN }} asset_name: tabcmd file: artifacts/tabcmd/tabcmd - tag: ${{ github.event.release.tag_name }} + tag: ${{ steps.tag.outputs.tag }} overwrite: true - name: Upload tabcmd-x86.app.tar (macOS x86) to release @@ -157,7 +182,7 @@ jobs: repo_token: ${{ secrets.GITHUB_TOKEN }} asset_name: tabcmd-x86.app.tar file: artifacts/tabcmd-x86.app.tar/tabcmd-x86.app.tar - tag: ${{ github.event.release.tag_name }} + tag: ${{ steps.tag.outputs.tag }} overwrite: true - name: Upload tabcmd_arm64.app.tar (macOS ARM64) to release @@ -166,5 +191,5 @@ jobs: repo_token: ${{ secrets.GITHUB_TOKEN }} asset_name: tabcmd_arm64.app.tar file: artifacts/tabcmd_arm64.app.tar/tabcmd_arm64.app.tar - tag: ${{ github.event.release.tag_name }} + tag: ${{ steps.tag.outputs.tag }} overwrite: true From 04c0fa2c8db8689b24f7ada531dcd608b620a654 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Fri, 18 Sep 2026 18:23:46 -0700 Subject: [PATCH 4/4] docs(contributing): document release-publish reviewer gate + manual re-attach Adds a Publishing-a-release subsection covering the release:published path fired by publishing the draft release in the UI, the new `environment: release` reviewer-approval gate that pauses upload_to_release until a maintainer clicks "Review deployments", and the manual re-attach command `gh workflow run package.yml --ref ` for existing releases. Requested during Copilot review of #459. --- contributing.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/contributing.md b/contributing.md index 97ee80d9..7a71c989 100644 --- a/contributing.md +++ b/contributing.md @@ -11,6 +11,7 @@ * [Releases](#releases) * [Versioning](#versioning) * [Packaging](#packaging) + * [Publishing a release](#publishing-a-release) ## Install Tabcmd @@ -176,6 +177,25 @@ To run the newly created executable, from a console window in the same directory To investigate what's packaged in the executable, use https://pyinstxtractor-web.netlify.app/ +### Publishing a release + +Merging into `main` triggers `release-on-merge.yml`, which creates a **draft** release, pushes the version tag, and dispatches `package.yml` to build the platform binaries. + +When you then **publish** that draft release in the GitHub UI: + +1. `package.yml` fires again on the `release: published` event and rebuilds the binaries against the exact tagged commit. +2. Before binaries are attached, the workflow **pauses at the `upload_to_release` job** waiting for a reviewer to approve the `release` environment. Go to **Actions → the latest `Package-and-Upload` run → Review deployments → Approve**. The four binaries (Windows `.exe`, Ubuntu `tabcmd`, macOS x86 and arm64 `.app.tar`) get attached after approval. +3. Separately, `publish-pypi.yml` uploads the wheel to PyPI, also gated on the `release` environment. + +If binaries need to be re-attached to an existing release (e.g. one build leg originally failed), dispatch the workflow at the target tag: + +```shell +gh workflow run package.yml --ref v2.1.0 +``` + +Same reviewer gate applies. + + ## Release process 1. Create a new Github project release manually: https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases