name: Build WinPython (cycle file) # One dispatch builds a whole cycle. Everything cycle-specific comes from # cycles/.toml, so a new cycle is a new TOML rather than a copy of this # workflow, and "all" builds every Python that cycle has lockfiles for. # # Leave publish on and each leg uploads its own output straight to a draft # release, instead of leaving gigabytes of artifacts to download and re-upload # by hand. The tag comes from the cycle file, so re-running one missing flavor # adds it to the release the others are already on. Turn publish off and the # output stays artifacts, as before. on: workflow_dispatch: inputs: cycle: description: 'Build cycle (file name in cycles/, without .toml)' required: true default: '2026_04' type: string python_versionf: description: 'Python to build; "all" takes every one this cycle has lockfiles for' required: true default: 'all' type: choice options: - 'all' - '3.13' - '3.14' - '3.14F' - '3.15' - '3.15F' publish: description: 'Upload to the draft release the cycle file names; off keeps the output as artifacts' required: false default: true type: boolean overwrite_published: description: 'Tick ONLY to replace the files of an already-published release; normally a release_level bump is what you want' required: false default: false type: boolean permissions: {} env: WINPYARCH: "64" dotwheelhouse: "dotpython\\wheelhouse\\included.wheels" jobs: config: # reads the cycle file once, instead of once per matrix leg runs-on: ubuntu-latest permissions: contents: read outputs: pandoc_source: ${{ steps.cfg.outputs.pandoc_source }} pandoc_sha256: ${{ steps.cfg.outputs.pandoc_sha256 }} release_level: ${{ steps.cfg.outputs.release_level }} release_tag: ${{ steps.cfg.outputs.release_tag }} release_title: ${{ steps.cfg.outputs.release_title }} matrix: ${{ steps.cfg.outputs.matrix }} steps: - uses: actions/checkout@v6 with: persist-credentials: false - name: Read cycle configuration id: cfg env: CYCLE: ${{ inputs.cycle }} PYTHON_VERSIONF: ${{ inputs.python_versionf }} run: python .github/scripts/cycle_config.py "cycles/$CYCLE.toml" "$PYTHON_VERSIONF" release-draft: # one job opens the draft, so the build legs cannot race to create it needs: config runs-on: ubuntu-latest permissions: contents: write steps: - name: Open the draft release if: ${{ inputs.publish }} env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} TAG: ${{ needs.config.outputs.release_tag }} TITLE: ${{ needs.config.outputs.release_title }} CYCLE: ${{ inputs.cycle }} OVERWRITE: ${{ inputs.overwrite_published }} run: | if gh release view "$TAG" >/dev/null 2>&1; then # Adding a missing flavor to a draft is the normal path. Landing on # a release that is already published is almost always a forgotten # release_level bump, and the upload replaces files people have # downloaded -- so stop here, before an hour of build time. published=$(gh release view "$TAG" --json isDraft --jq '.isDraft | not') if [ "$published" = "true" ] && [ "$OVERWRITE" != "true" ]; then echo "::error::$TAG is already published. Bump release_level in cycles/$CYCLE.toml, or re-dispatch with overwrite_published ticked to replace its files." exit 1 fi # say which release the files land on, and leave the title alone in # case it was edited by hand state=$(gh release view "$TAG" --json isDraft --jq 'if .isDraft then "still a draft" else "ALREADY PUBLISHED" end') echo "release $TAG exists ($state); this run adds its files to it" else gh release create "$TAG" --draft \ --title "$TITLE" \ --notes "Draft opened by the build workflow for cycle $CYCLE. Files land as each build finishes; publish once they are all here." echo "opened draft release $TAG" fi build-winpython: needs: [config, release-draft] name: ${{ matrix.leg.python_versionf }} ${{ matrix.leg.name }} runs-on: windows-latest permissions: contents: write # gh release upload; the checkout itself only reads strategy: fail-fast: false # a cycle is eight legs or more: one bad flavor must not bin the rest matrix: ${{ fromJSON(needs.config.outputs.matrix) }} env: WINPYFLAVOR: ${{ matrix.leg.name }} WINPYVER: ${{ matrix.leg.winpyver }} WINPYVER2: ${{ matrix.leg.ver2 }} build_location: ${{ matrix.leg.build_location }} destwheelhouse: ${{ matrix.leg.destwheelhouse }} WINPYLOCKFILE: ${{ matrix.leg.lockfile }} WINPYLOCKFILEwhl: ${{ matrix.leg.lockfile_wheels }} steps: - name: Checkout repository uses: actions/checkout@v6 with: persist-credentials: false - name: Download, verify and extract python standalone uses: ./.github/actions/python-setup with: python_source: ${{ matrix.leg.src }} python_sha256: ${{ matrix.leg.sha }} build_location: ${{ env.build_location }} - name: Download, checking hash and integrating pandoc binary if: matrix.leg.PANDOC == '1' uses: ./.github/actions/pandoc-setup with: pandoc_source: ${{ needs.config.outputs.pandoc_source }} pandoc_sha256: ${{ needs.config.outputs.pandoc_sha256 }} build_location: ${{ env.build_location }} - name: Upgrade pip and patch launchers shell: pwsh run: | & "$env:build_location\python\python.exe" -m pip install --upgrade --force-reinstall pip --no-warn-script-location & "$env:build_location\python\python.exe" -c "from wppm import wppm;dist=wppm.Distribution();dist.patch_standard_packages('pip', to_movable=True)" - name: Download all requirements shell: pwsh run: | $py = "$env:build_location\python\python.exe" & $py -m pip download --dest $env:dotwheelhouse --no-deps --require-hashes -r $env:WINPYLOCKFILE if ($env:WINPYLOCKFILEwhl -ne '') { & $py -m pip download --dest $env:destwheelhouse --no-deps --require-hashes -r $env:WINPYLOCKFILEwhl } - name: Install lockfile shell: pwsh run: | & "$env:build_location\python\python.exe" -m pip install --no-deps --require-hashes -r $env:WINPYLOCKFILE --no-warn-script-location - name: Generate Assets and Hashes uses: ./.github/actions/publish-winpython with: build_location: ${{ env.build_location }} winpy_flavor: ${{ env.WINPYFLAVOR }} winpy_arch: ${{ env.WINPYARCH }} winpy_ver: ${{ env.WINPYVER }} winpy_ver2: ${{ env.WINPYVER2 }} release_level: ${{ needs.config.outputs.release_level }} dotwheelhouse: ${{ env.dotwheelhouse }} winpy_requirements_whl: ${{ matrix.leg.requirements_wheels }} format_zip: ${{ matrix.leg.formats.zip }} format_7z: ${{ matrix.leg.formats['7z'] }} format_exe: ${{ matrix.leg.formats.exe }} - name: Upload to the draft release if: ${{ inputs.publish }} shell: bash env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} TAG: ${{ needs.config.outputs.release_tag }} run: | shopt -s nullglob # the metadata is worth little on its own: fail the leg if no binary came out binaries=(publish_output/*.exe publish_output/*.zip publish_output/*.7z) if [ ${#binaries[@]} -eq 0 ]; then echo "::error::no binary in publish_output for $WINPYVER" exit 1 fi files=(publish_output/*) # --clobber so re-running a leg replaces its files instead of erroring gh release upload "$TAG" "${files[@]}" --clobber - name: Upload metadata artifact # the binaries are on the release; this is what the changelog commit needs if: ${{ inputs.publish }} uses: actions/upload-artifact@v6 with: name: ${{ matrix.leg.artifact_name }} path: | publish_output/*.md publish_output/*.toml publish_output/*.txt retention-days: 66 # keeps artifact for 66 days - name: Upload artifacts # no release to upload to, so the binaries have nowhere else to go if: ${{ !inputs.publish }} uses: actions/upload-artifact@v6 with: name: ${{ matrix.leg.artifact_name }} path: publish_output retention-days: 66 # keeps artifact for 66 days changelogs: # A cycle's changelogs, filed and offered as one reviewable pull request. # # Whole-cycle builds only. A re-run of a single leg would otherwise reduce # the branch to that leg's files, and it has nothing to add anyway: the # same lockfile produces the same package list, so a rebuilt leg cannot # change a changelog. needs: [config, build-winpython] if: ${{ inputs.publish && inputs.python_versionf == 'all' }} runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - name: Checkout repository # credentials are kept here, unlike in the build legs: this job pushes uses: actions/checkout@v6 - name: Install Python uses: actions/setup-python@v6 with: python-version: '3.13' - name: Install pinned dependencies # the same hash-pinned set the test suite uses; diff.py wants packaging run: python -m pip install --no-deps --require-hashes -r tests/requir.wppmtest.txt - name: Collect the metadata every leg produced uses: actions/download-artifact@v6 with: pattern: publish_* merge-multiple: true path: release_metadata - name: File the changelogs run: python .github/scripts/changelog_files.py release_metadata changelogs - name: Open the changelog pull request env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} TAG: ${{ needs.config.outputs.release_tag }} TITLE: ${{ needs.config.outputs.release_title }} BASE: ${{ github.event.repository.default_branch }} run: | branch="changelogs/$TAG" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git switch -c "$branch" git add changelogs if git diff --cached --quiet; then echo "changelogs/ already holds this cycle; nothing to open" exit 0 fi git commit -m "Changelogs for $TITLE" # the branch is generated wholly by this job, so a re-run replaces it git push --force origin "$branch" if gh pr view "$branch" --json number >/dev/null 2>&1; then echo "pull request for $branch is open; the push updated it" else gh pr create --base "$BASE" --head "$branch" \ --title "Changelogs for $TITLE" \ --body "Package indexes, lock files and requirements for every leg of \`$TAG\`, filed by the build that produced them rather than copied by hand. Compare any two of them with \`wppm -diff .md .md\`." fi