diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 9413ed6a..d71bbb03 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -9,15 +9,30 @@ 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: - - '*' + release: + types: [published] + # Manual repair path for a published release. The release tag is resolved from + # the release event, then inputs.release_tag, then github.ref_name when + # dispatched at a tag ref. Don't dispatch for a draft release: the upload step + # finds the release by tag, drafts can't be found that way, and the action then + # creates a second release for the same tag. + # There is deliberately no `push: tags` trigger: release-on-merge.yml pushes a + # tag on every merge to main, and a build should only run once a release is published. workflow_dispatch: + inputs: + release_tag: + description: 'Release tag to attach binaries to. Required when dispatching from a branch rather than a tag ref.' + required: false + type: string + +concurrency: + group: package-${{ github.event.release.tag_name || inputs.release_tag || github.ref_name }} + cancel-in-progress: false 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 +112,103 @@ 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 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 + # release upload where e.g. macOS is missing is never possible. + upload_to_release: + name: Attach build artifacts to release + needs: buildexe + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + steps: + # download-artifact versions independently of upload-artifact; v7 reads the + # zipped artifacts that upload-artifact@v7 produces by default + - name: Download all build artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts/ + + # Resolve the release tag, in order of preference: the release event's + # tag_name, the workflow_dispatch release_tag input, then github.ref_name + # when the run was dispatched from a tag ref. A dispatch from a branch + # with no release_tag fails fast rather than treating the branch name as + # a tag, and an empty result never reaches the upload steps. + - name: Resolve release tag + id: tag + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.release_tag }} + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + EVENT_NAME: ${{ github.event_name }} + run: | + TAG="$RELEASE_TAG" + if [ -z "$TAG" ]; then + TAG="$INPUT_TAG" + fi + if [ -z "$TAG" ] && [ "$REF_TYPE" = "tag" ]; then + TAG="$REF_NAME" + fi + if [ -z "$TAG" ] && [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "::error::workflow_dispatch from $REF_TYPE '$REF_NAME' has no release_tag input; pass -f release_tag= or dispatch with --ref " + exit 1 + fi + if [ -z "$TAG" ]; then + echo "::error::No release tag resolvable (event=$EVENT_NAME, release.tag_name empty, release_tag input empty, ref_type=$REF_TYPE)" + 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: ${{ steps.tag.outputs.tag }} + 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: ${{ steps.tag.outputs.tag }} + 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: ${{ steps.tag.outputs.tag }} + 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: ${{ steps.tag.outputs.tag }} overwrite: true diff --git a/.github/workflows/release-on-merge.yml b/.github/workflows/release-on-merge.yml index cb324af0..7175747a 100644 --- a/.github/workflows/release-on-merge.yml +++ b/.github/workflows/release-on-merge.yml @@ -1,10 +1,9 @@ name: Draft release on merge to main # Triggers when a PR is merged into main (push to main covers all merge strategies). -# Creates a draft GitHub release with auto-generated notes, pushes the version tag, -# and explicitly dispatches package.yml to build and attach binaries. -# (Tag pushes from GITHUB_TOKEN do not trigger other workflows, so we dispatch explicitly.) -# When you publish the draft release, publish-pypi.yml uploads to PyPI automatically. +# Pushes the next patch version tag and creates a draft GitHub release with auto-generated notes. +# Nothing is built here. When you publish the draft release, package.yml builds and attaches +# the binaries and publish-pypi.yml uploads to PyPI. on: push: @@ -59,9 +58,3 @@ jobs: --title "$TAG" \ --generate-notes \ --notes-start-tag "$LAST_TAG" - - - name: Trigger binary builds - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.version.outputs.tag }} - run: gh workflow run package.yml --ref "$TAG" diff --git a/contributing.md b/contributing.md index 97ee80d9..0368dd27 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,26 +177,43 @@ 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 pushes the next patch version tag and creates a **draft** release for it. Nothing is built at this point. + +When you then **publish** that draft release in the GitHub UI: + +1. `package.yml` fires on the `release: published` event and builds the binaries against the 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, gated on the `pypi` environment. + +If binaries need to be re-attached to a published release (e.g. one build leg originally failed), dispatch the workflow at the target tag. This doesn't work for a draft release: the upload step looks the release up by tag, and GitHub doesn't return drafts by tag. + +```shell +gh workflow run package.yml --ref v2.1.0 +``` + +To dispatch from a branch (e.g. to test workflow changes), pass the release to attach to: `gh workflow run package.yml --ref -f release_tag=v2.1.0`. A branch dispatch without `release_tag` fails at the tag-resolution step. + +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 +Releases are created and published as described in [Publishing a release](#publishing-a-release). The manual steps are: + +1. Edit the draft release created by `release-on-merge.yml` before publishing it: - include a useful list of changes since the last release - include a clear list of remaining non-server-admin functional gaps -1. This will trigger our github packaging action to run on 3 different OSs. +1. Publish the release. This triggers `package.yml` and `publish-pypi.yml`: - write an updated metadata file with the correct version number. - build the python wheel - - run pyinstaller to create executables - - save the executable as an artifact on that job. - -1. Find the artifacts created by this job and manually copy them to the new release. -- manually download. They will all be returned as zips -- unzip the windows.exe and mac.app.tar files and upload those -- do not unzip the linux app, github doesn't like it. upload as tabcmd.zip -(Pay attention to what the file type is, github also sends it as a zip if you download with curl etc. TODO: automate workflow with a github action) + - run pyinstaller to create executables on each OS + - after a reviewer approves the `release` environment, attach the executables to the release. + - upload the wheel to PyPI (after approval of the `pypi` environment). -1. To trigger publishing to pypi run the manual workflow on main with 'pypi'. +1. To publish to PyPI without a release (or to test on Test PyPI), run the manual `Publish to PyPi` workflow on main. 1. When the packages are available on pypi, you can run the 'Pypi smoke test action'. This action will also be run every 24 hours to validate doing pip install. (TODO: automate the after-release trigger) -