diff --git a/.github/workflows/cron-conda.yml b/.github/workflows/cron-conda.yml index 0241644f1a..f52b99fd56 100644 --- a/.github/workflows/cron-conda.yml +++ b/.github/workflows/cron-conda.yml @@ -17,14 +17,27 @@ on: # tests, the Windows leg's included, can be debugged without waiting for # Saturday: `conda-update` refreshes and bumps but pushes nothing, and # `conda-dist` builds that tree. No step that pushes, tags, uploads or posts - # runs on this event, and no job enters an environment. + # runs, and no job enters an environment -- unless `publish` is set (#2060), + # which `mode` refuses off `main`, and which then publishes as a scheduled + # bump does, with the build bumped even when nothing changed. workflow_dispatch: + inputs: + publish: + description: >- + Publish from main: bump the build even when nothing changed, then + push, tag, upload every platform and post the report, as a scheduled + bump does. Refused on any other ref. Unset, the run only builds. + type: boolean + required: false + default: false # Never cancelled in progress: a cancelled run can leave the bump half-applied, # and two runs uploading one version to Anaconda is worse than dropping a # superseded duplicate. Keyed as cron-vendor.yml's group is, and why (#1643); # `conda-update`'s `repository-maintenance` group serialises the writes. A manual -# run writes nothing, so it is keyed on itself in both, as in cron-vendor.yml. +# run is keyed on itself here, so it never displaces a pending scheduled run. +# One without `publish` writes nothing, so it is keyed on itself in +# `conda-update` too, as in cron-vendor.yml; one with it joins that group. concurrency: group: conda-update-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event.workflow_run.head_sha || github.ref }} cancel-in-progress: false @@ -35,8 +48,43 @@ env: REPORT_CATEGORY: CI reports jobs: - # #1967: the test gate on the schedule, `verdict` on a `workflow_run`; - # shared with Vendor Update and GitHub Pages. + # #2060: whether this run publishes, and the only job reading the `publish` + # input. `publishes` is 'true' on the schedule, on a `workflow_run`, and on a + # manual run with `publish` from `main`; 'false' on any other manual run. One + # with `publish` from another ref fails here, which fails the run and stops + # `conda-update` and every job after it. Each step and job that pushes, + # tags, uploads, posts or names an environment opens only on a 'true', so + # this job's skip on a pull request's Unit Tests run, which `conda-update` + # refuses anyway, publishes nothing either. + mode: + name: Conda run mode + if: github.event_name != 'workflow_run' || github.event.workflow_run.event == 'push' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + publishes: ${{ steps.mode.outputs.publishes }} + steps: + - name: Decide whether this run publishes + id: mode + env: + PUBLISH: ${{ inputs.publish }} + run: | + if [ "$GITHUB_EVENT_NAME" != workflow_dispatch ]; then + echo "publishes=true" >> "$GITHUB_OUTPUT" + elif [ "$PUBLISH" != true ]; then + echo "publishes=false" >> "$GITHUB_OUTPUT" + elif [ "$GITHUB_REF" = refs/heads/main ]; then + echo "::notice title=Publishing manual run::publish is set on main, so this run bumps the build and pushes, tags, uploads and posts as a scheduled bump does" + echo "publishes=true" >> "$GITHUB_OUTPUT" + else + echo "::error title=publish is for main only::${GITHUB_REF} was dispatched with publish set; dispatch main to publish, or leave publish unset for a build-only run" + exit 1 + fi + + # #1967: the test gate on the schedule and a manual run, `verdict` on a + # `workflow_run`; shared with Vendor Update and GitHub Pages, and called the + # same way from each, so it does not wait for `mode`. head: name: Conda gate permissions: @@ -47,13 +95,14 @@ jobs: name: Update requirements.txt runs-on: ubuntu-latest # #1968: as for cron-vendor.yml's `vendor-update`, which says why a - # manual run names none. - environment: ${{ github.event_name != 'workflow_dispatch' && 'conda-tag' || '' }} - needs: [ head ] + # manual run names none; one with `publish` does (#2060). + environment: ${{ needs.mode.outputs.publishes == 'true' && 'conda-tag' || '' }} + needs: [ head, mode ] # As cron-vendor.yml's `vendor-update` (#1643); without the `push`/`main` # checks a green PR would upload a conda distribution built from its tree. + # `mode` must succeed, so a refused manual run builds nothing. if: >- - !cancelled() && needs.head.result != 'failure' && + !cancelled() && needs.mode.result == 'success' && needs.head.result != 'failure' && (github.event_name != 'workflow_run' || ( github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && @@ -61,13 +110,13 @@ jobs: needs.head.outputs.verified == 'true' )) concurrency: - group: ${{ github.event_name == 'workflow_dispatch' && format('repository-maintenance-{0}', github.run_id) || 'repository-maintenance' }} + group: ${{ needs.mode.outputs.publishes == 'true' && 'repository-maintenance' || format('repository-maintenance-{0}', github.run_id) }} cancel-in-progress: false permissions: contents: write steps: - name: Load maintenance deploy key - if: github.event_name != 'workflow_dispatch' + if: needs.mode.outputs.publishes == 'true' uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 with: ssh-private-key: ${{ secrets.PYPCAPKIT_ACTIONS_DEPLOY_KEY }} @@ -140,13 +189,32 @@ jobs: echo "files_changed=false" >> "$GITHUB_OUTPUT" fi + # #2060: a manual run with `publish` bumps even when nothing changed, so + # its build is one Anaconda has no file for and every `conda-dist` leg + # uploads; util/conda_vendor.py regenerates the same recipe when no + # dependency moved. Any other run bumps only on a change. `forced` says + # which, for Push Changes and the run report: no later run repeats a + # forced bump that is not pushed. + - name: Decide whether to bump + id: bump + env: + FILES_CHANGED: ${{ steps.verify-changed-files.outputs.files_changed }} + FORCED: ${{ github.event_name == 'workflow_dispatch' && needs.mode.outputs.publishes == 'true' }} + run: | + if [ "$FILES_CHANGED" = true ] || [ "$FORCED" = true ]; then + echo "bump=true" >> "$GITHUB_OUTPUT" + else + echo "bump=false" >> "$GITHUB_OUTPUT" + fi + echo "forced=$FORCED" >> "$GITHUB_OUTPUT" + - name: Bump Version - if: steps.verify-changed-files.outputs.files_changed == 'true' + if: steps.bump.outputs.bump == 'true' run: | python util/conda-build.py - name: Commit Changes - if: steps.verify-changed-files.outputs.files_changed == 'true' + if: steps.bump.outputs.bump == 'true' env: CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }} run: | @@ -155,7 +223,7 @@ jobs: git commit -am"Bumped build to $(cat conda/build) Updated conda distribution at $(date). The following files have been changed: - $CHANGED_FILES" + ${CHANGED_FILES:-none, as a manual run with publish bumps regardless}" # #1632: writes `pushed` and `commit` for `conda-tag` and `conda-dist`. # The verdict covers `$GITHUB_SHA` only, so the commits a rebase onto the @@ -172,9 +240,16 @@ jobs: # 38325136710 and 38325153882, both on 2e941bbb7). Asking about the file # also refuses a rebase that keeps part of the commit but drops its bump, # which would land under a build already tagged. + # + # #2060: a forced bump is one no later run repeats, so where it is not + # pushed each notice is a warning instead: nothing was published, and + # Conda Update has to be dispatched again with `publish` on `main`. Where + # another run already pushed the build, that run tags and uploads it. - name: Push Changes id: push-changes - if: github.event_name != 'workflow_dispatch' && steps.verify-changed-files.outputs.files_changed == 'true' + if: needs.mode.outputs.publishes == 'true' && steps.bump.outputs.bump == 'true' + env: + FORCED: ${{ steps.bump.outputs.forced }} run: | git remote set-url origin git@github.com:JarryShaw/PyPCAPKit.git git fetch origin "${GITHUB_REF#refs/heads/}" @@ -187,7 +262,11 @@ jobs: echo "$reason" ;; 3) - echo "::notice title=Not pushed as main moved past this checkout::$reason The bump is left to the Conda Update that $tip's own Unit Tests run raises." + if [ "$FORCED" = true ]; then + echo "::warning title=Nothing published as main moved past this checkout::$reason No later run repeats this forced bump, so nothing was published: dispatch Conda Update again with publish on main, now at $tip." + else + echo "::notice title=Not pushed as main moved past this checkout::$reason The bump is left to the Conda Update that $tip's own Unit Tests run raises." + fi echo "pushed=false" >> "$GITHUB_OUTPUT" exit 0 ;; @@ -203,7 +282,11 @@ jobs: git diff --quiet "$tip" HEAD -- conda/build || status=$? case "$status" in 0) - echo "::notice title=Build already on main::conda/build $(cat conda/build) was pushed by another run; nothing to push, tag or upload" + if [ "$FORCED" = true ]; then + echo "::warning title=Nothing published as the build is already on main::conda/build $(cat conda/build) was pushed by another run, which tags and uploads it, so this run published nothing; if that run did not publish it, dispatch Conda Update again with publish on main" + else + echo "::notice title=Build already on main::conda/build $(cat conda/build) was pushed by another run; nothing to push, tag or upload" + fi echo "pushed=false" >> "$GITHUB_OUTPUT" ;; 1) @@ -222,7 +305,11 @@ jobs: if [ "$moved" = "$tip" ] || [ "$ancestry" -ne 1 ]; then exit "$status" fi - echo "::notice title=Not pushed as main moved during the push::main moved from $tip to $moved after the fetch, so $commit is not a fast-forward. The bump is left to the Conda Update that $moved's own Unit Tests run raises." + if [ "$FORCED" = true ]; then + echo "::warning title=Nothing published as main moved during the push::main moved from $tip to $moved after the fetch, so $commit is not a fast-forward. No later run repeats this forced bump, so nothing was published: dispatch Conda Update again with publish on main, now at $moved." + else + echo "::notice title=Not pushed as main moved during the push::main moved from $tip to $moved after the fetch, so $commit is not a fast-forward. The bump is left to the Conda Update that $moved's own Unit Tests run raises." + fi echo "pushed=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -234,9 +321,9 @@ jobs: ;; esac - # A manual run builds whether or not anything changed. + # A build-only run builds whether or not anything changed. - name: Get Version - if: steps.verify-changed-files.outputs.files_changed == 'true' || github.event_name == 'workflow_dispatch' + if: steps.bump.outputs.bump == 'true' || needs.mode.outputs.publishes != 'true' id: get_version run: | set -ex @@ -247,11 +334,11 @@ jobs: echo "PCAPKIT_BUILD=$(cat conda/build)" } >> "$GITHUB_OUTPUT" - # A manual run pushes nothing, so `conda-dist` gets this job's changes as - # a patch on the dispatched commit. The first line keeps the file from + # A build-only run pushes nothing, so `conda-dist` gets this job's changes + # as a patch on the dispatched commit. The first line keeps the file from # being empty when nothing changed. - name: Save the changes for conda-dist - if: github.event_name == 'workflow_dispatch' + if: needs.mode.outputs.publishes != 'true' run: | { echo "Conda Update's changes to $GITHUB_SHA" @@ -259,7 +346,7 @@ jobs: } > "$RUNNER_TEMP/conda-changes.patch" - name: Upload the changes for conda-dist - if: github.event_name == 'workflow_dispatch' + if: needs.mode.outputs.publishes != 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: conda-changes @@ -268,14 +355,17 @@ jobs: # #2030: the run's outcome for the `discussion` job to post: what the pin # refresh moved or warned about, the files the run changed, and the build - # it pushed. `status=warning` where a pin warned, which is what needs a - # hand. `continue-on-error` on both, so a report never fails the bump. + # it pushed. `status=warning` where a pin warned, or where a forced bump + # was not pushed (#2060), which is what needs a hand. `continue-on-error` + # on both, so a report never fails the bump. - name: Write the run report id: run-report if: ${{ !cancelled() }} continue-on-error: true env: - EVENT_NAME: ${{ github.event_name }} + PUBLISHES: ${{ needs.mode.outputs.publishes }} + BUMP: ${{ steps.bump.outputs.bump }} + FORCED: ${{ steps.bump.outputs.forced }} FILES_CHANGED: ${{ steps.verify-changed-files.outputs.files_changed }} CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }} PUSH: ${{ steps.push-changes.outcome }} @@ -284,6 +374,8 @@ jobs: pins="$RUNNER_TEMP/platform-pins.log" if grep -q '^::warning title=Platform pins::' "$pins" 2> /dev/null; then echo "status=warning" >> "$GITHUB_OUTPUT" + elif [ "$FORCED" = true ] && [ -z "$COMMIT" ]; then + echo "status=warning" >> "$GITHUB_OUTPUT" else echo "status=success" >> "$GITHUB_OUTPUT" fi @@ -303,14 +395,18 @@ jobs: fi if [ "$FILES_CHANGED" = true ]; then echo "- Changed: $CHANGED_FILES" + elif [ "$BUMP" = true ]; then + echo '- Changed: nothing, and the build was bumped anyway, as a manual run with publish asks' elif [ "$FILES_CHANGED" = false ]; then echo '- Changed: nothing, so no build was bumped' fi # `commit` is written only beside `pushed=true`. - if [ "$EVENT_NAME" = workflow_dispatch ]; then + if [ "$PUBLISHES" != true ]; then echo '- Pushed: nothing, as a manual run only builds' elif [ -n "$COMMIT" ]; then echo "- Pushed: build $(cat conda/build) as $COMMIT" + elif [ "$PUSH" = success ] && [ "$FORCED" = true ]; then + echo "- Pushed: nothing, so this manual run with publish published nothing; Push Changes' warning says why, and when to dispatch it again" elif [ "$PUSH" = success ]; then echo "- Pushed: nothing; Push Changes' notice says why" elif [ "$PUSH" = failure ]; then @@ -350,7 +446,7 @@ jobs: # #1968: the deploy key and the `PYPCAPKIT` PAT are `conda-tag` environment # secrets, as for create-release.yml's `tag`; keep it free of reviewers. environment: conda-tag - needs: [ conda-update ] + needs: [ conda-update, mode ] # #1632: `!cancelled()`, since `head`'s jobs skip on every path (#1967) and # without a status-check function "a default status check of `success()` # is applied" (GitHub, "Evaluate expressions in workflows and actions"). @@ -365,9 +461,9 @@ jobs: # failed or cancelled update, which still publishes its finished steps' # outputs, is never tagged. Same shape as create-release.yml's `tag` job. # - # Never on a manual run, which pushed nothing to tag. + # Never on a build-only run, which pushed nothing to tag. if: >- - !cancelled() && github.event_name != 'workflow_dispatch' && + !cancelled() && needs.mode.outputs.publishes == 'true' && needs.conda-update.result == 'success' && needs.conda-update.outputs.CONDA_CHANGED == 'true' permissions: @@ -428,18 +524,18 @@ jobs: name: Conda deployment (update) on ${{ matrix.os }} with Python ${{ matrix.python-version }} runs-on: ${{ matrix.os }} # #1968: `ANACONDA_TOKEN` is an `anaconda` environment secret, as for - # create-release.yml's `conda`; keep it free of reviewers. A manual run, - # whose uploads all skip, names no environment. - environment: ${{ github.event_name != 'workflow_dispatch' && 'anaconda' || '' }} - needs: [ conda-tag, conda-update ] + # create-release.yml's `conda`; keep it free of reviewers. A build-only + # run, whose uploads all skip, names no environment. + environment: ${{ needs.mode.outputs.publishes == 'true' && 'anaconda' || '' }} + needs: [ conda-tag, conda-update, mode ] # #1632, as for `conda-tag`. `conda-tag` must succeed, with no `skipped` # escape as create-release.yml's `conda` job grants its `tag`: the checkout # pins the tag, which names the pushed commit only then, and `conda-tag` - # skips only when no bump was pushed. A manual run, which never tags, + # skips only when no bump was pushed. A build-only run, which never tags, # builds the dispatched commit with `conda-update`'s changes applied. if: >- !cancelled() && needs.conda-update.result == 'success' && - (github.event_name == 'workflow_dispatch' || ( + (needs.mode.outputs.publishes != 'true' || ( needs.conda-tag.result == 'success' && needs.conda-update.outputs.CONDA_CHANGED == 'true' )) @@ -464,17 +560,17 @@ jobs: steps: # No persisted token: nothing here runs `git` with credentials (#2060). - id: checkout - if: github.event_name != 'workflow_dispatch' + if: needs.mode.outputs.publishes == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: conda-${{ needs.conda-update.outputs.PCAPKIT_VERSION }}+${{ needs.conda-update.outputs.PCAPKIT_BUILD }} fetch-depth: 0 persist-credentials: false - # A manual run: no tag exists, so the dispatched commit, and then the + # A build-only run: no tag exists, so the dispatched commit, and then the # changes `conda-update` made to it and did not push. - id: checkout-dispatched - if: github.event_name == 'workflow_dispatch' + if: needs.mode.outputs.publishes != 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -482,7 +578,7 @@ jobs: - name: Download conda-update's changes id: download-changes - if: github.event_name == 'workflow_dispatch' + if: needs.mode.outputs.publishes != 'true' uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: conda-changes @@ -490,7 +586,7 @@ jobs: - name: Apply conda-update's changes id: apply-changes - if: github.event_name == 'workflow_dispatch' + if: needs.mode.outputs.publishes != 'true' shell: bash env: PATCH_FILE: ${{ runner.temp }}/conda-changes/conda-changes.patch @@ -594,9 +690,9 @@ jobs: # #2044: not on macos-26-intel, whose leg "Upload conda packages to # Anaconda (Intel)" uploads instead, nor, #2060, on windows-latest, whose # "Upload conda packages to Anaconda (Windows)" does. No upload, nor the - # GitHub Release steps after them, runs on a manual run. + # GitHub Release steps after them, runs on a build-only run. - name: Upload conda packages to Anaconda - if: ${{ github.event_name != 'workflow_dispatch' && matrix.os != 'macos-26-intel' && matrix.os != 'windows-latest' }} + if: ${{ needs.mode.outputs.publishes == 'true' && matrix.os != 'macos-26-intel' && matrix.os != 'windows-latest' }} uses: anaconda/actions/upload-package@a6f488910b4a1e4f3fe01736241ca1b92b4b355a # v0.3.1 with: token: ${{ secrets.ANACONDA_TOKEN }} @@ -609,7 +705,7 @@ jobs: # from the action here: `--skip-existing` keeps a file anaconda.org # already has, where the action's `force: true` replaces it. - name: Upload conda packages to Anaconda (Intel) - if: ${{ github.event_name != 'workflow_dispatch' && matrix.os == 'macos-26-intel' }} + if: ${{ needs.mode.outputs.publishes == 'true' && matrix.os == 'macos-26-intel' }} shell: bash env: ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_TOKEN }} @@ -622,7 +718,7 @@ jobs: # #2060: see create-release.yml's step of the same name, which says why # it runs under a login shell on conda's base `python`. - name: Upload conda packages to Anaconda (Windows) - if: ${{ github.event_name != 'workflow_dispatch' && matrix.os == 'windows-latest' }} + if: ${{ needs.mode.outputs.publishes == 'true' && matrix.os == 'windows-latest' }} shell: bash -el {0} env: ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_TOKEN }} @@ -643,7 +739,7 @@ jobs: # the GitHub upload is skipped; the build is on anaconda.org regardless. - name: Check the GitHub release exists id: release - if: ${{ github.event_name != 'workflow_dispatch' && matrix.os == 'ubuntu-latest' }} + if: ${{ needs.mode.outputs.publishes == 'true' && matrix.os == 'ubuntu-latest' }} shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -670,7 +766,7 @@ jobs: # ``commit`` is sent to Update a release as ``target_commitish`` # (src/Action.ts:107-120, src/Releases.ts:150-174), and - # ``github.ref_name`` is the default branch on both triggers here, so + # ``github.ref_name`` is the default branch on every run that publishes, so # GitHub's workflow-file rule checks main, not the commit Create Release # made the release at. The tag stays: the target is "Unused if the Git # tag already exists" (REST docs, Update a release). @@ -681,7 +777,7 @@ jobs: # name and notes. Each ``omit*DuringUpdate`` leaves its field undefined # (:210-252), and Octokit drops it (dist/index.js:38369-38388). - name: Upload package to GitHub Release - if: ${{ github.event_name != 'workflow_dispatch' && matrix.os == 'ubuntu-latest' && steps.release.outputs.published == 'true' }} + if: ${{ needs.mode.outputs.publishes == 'true' && matrix.os == 'ubuntu-latest' && steps.release.outputs.published == 'true' }} uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: allowUpdates: true @@ -698,12 +794,13 @@ jobs: # short one of its own where `head` failed or was cancelled, as cron-vendor.yml's # `discussion` job does, whose comment gives the rules. It waits for # `conda-dist`, so the report says whether the build reached Anaconda. Never - # on a manual run, which the thread would read as a real one. + # on a build-only run, which the thread would read as a real one; a manual + # run with `publish` posts as the schedule does (#2060). discussion: name: Post the conda report - needs: [ head, conda-update, conda-tag, conda-dist ] + needs: [ head, conda-update, conda-tag, conda-dist, mode ] if: >- - ${{ always() && github.event_name != 'workflow_dispatch' && (needs.conda-update.result != 'skipped' || + ${{ always() && needs.mode.outputs.publishes == 'true' && (needs.conda-update.result != 'skipped' || needs.head.result == 'failure' || needs.head.result == 'cancelled') }} runs-on: ubuntu-latest timeout-minutes: 10 @@ -726,8 +823,9 @@ jobs: path: report # As cron-vendor.yml's: a `workflow_run` posts as a push run, the - # schedule every run. A failed tag or upload fails the report too, and a - # green `conda-update` that warned reports `warning`. + # schedule, and a manual run with `publish`, every run. A failed tag or + # upload fails the report too, and a green `conda-update` that warned + # reports `warning`. - name: Post the report to Discussions continue-on-error: true env: diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index 745af88fa0..6f3cc167fc 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -402,7 +402,8 @@ this run, it *skipped*. ``tag``, ``pypi`` and ``conda`` all guard against this the same way the other workflows gated on a test run already do for the identical problem -- ``cron-vendor.yml``, ``deploy-pages.yml`` and ``cron-conda.yml`` each open -with ``!cancelled() && needs.head.result != 'failure'``, ``head`` being their +with ``!cancelled()`` and require ``needs.head.result != 'failure'`` (and, in +``cron-conda.yml``, its ``mode`` job's success, :issue:`2060`), ``head`` being their call to ``cron-head.yml``, so that a skipped gate does not cascade-skip them, while an outright failure or a genuine run cancellation still does. ``tag``, ``pypi`` and ``conda`` are stricter about their *publishing* predecessors -- ``github``, and for diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index b7b1687288..a6e4be8cf0 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -393,14 +393,40 @@ exists`` step finds the release rather than skipping the GitHub upload To debug Vendor Update, Conda Update or GitHub Pages without waiting for Saturday, start a manual run on a branch, as in ``gh workflow run cron-conda.yml ---ref ``. A manual run is build-only: Cron Head's test gate and every -build and test step the schedule runs still run -- each ``conda-dist`` leg, +--ref ``. A manual run is build-only by default: Cron Head's test gate +and every build and test step the schedule runs still run -- each ``conda-dist`` leg, Windows included, builds the dispatched commit with ``conda-update``'s changes applied -- while every step that pushes, tags, uploads, deploys or posts a report skips, no job that runs names an environment, and the Create Release run it raises releases nothing. :file:`tests/project/test_build_only_dispatch.py` pins this. +Conda Update's manual run also takes a ``publish`` input, off by default, for +putting a version's missing platform files on Anaconda (:issue:`2060`). A +scheduled run uploads only a build it bumped, and it bumps only when +``Verify Changed files`` sees a change, which :file:`util/conda_vendor.py` does +not make when no dependency moved. ``gh workflow run cron-conda.yml --ref main +-f publish=true`` therefore runs as the schedule does, through the same test +gate, environments and ``repository-maintenance`` group, but with the build +bumped regardless: ``Decide whether to bump`` says so although nothing changed, +so ``Bump Version``, ``Commit Changes`` and ``Push Changes`` run, ``conda-tag`` +tags the pushed commit, every ``conda-dist`` leg uploads it, the GitHub +Release steps run, and ``discussion`` posts the report as ``scheduled``. The +``mode`` job is the one place the input is read: its ``publishes`` output is +``'true'`` on the schedule, on a ``workflow_run`` and on such a run, and +``'false'`` on any other manual run, and every step or job that publishes or +names an environment requires it to be ``'true'``. On any ref but ``main``, +``publish`` fails ``mode`` with an ``::error``, so the run fails and +``conda-update`` never starts. The workflow-level concurrency group stays keyed +on the run, so a manual run never displaces a pending scheduled one. +No later run repeats a forced bump, so where ``Push Changes`` does not push it +because ``main`` moved past the checkout or during the push, the run stays +green having published nothing, and says so in a warning and in its report +(:issue:`2060`); the remedy is to dispatch Conda Update again with ``publish`` +on ``main``. Where another run already pushed the same build, that run tags and +uploads it, and a second dispatch is needed only if it did not. +:file:`tests/project/test_conda_publish_dispatch_2060.py` pins this. + ``gate-only: true`` selects not only ``unit-tests.yml``'s ``gate`` (the full suite, on ``latest``) but also ``changelog`` (checks ``CHANGELOG.md`` against its source entry) and ``python-versions``, which reads ``gate``'s version (see @@ -1818,12 +1844,15 @@ it, so the scheduled jobs reading them name it too * Conda Update's ``conda-dist`` -- ``environment: anaconda`` * Vendor Update's ``vendor-update`` -- ``environment: conda-tag`` +A manual run names neither, except Conda Update's with ``publish``, whose jobs +name them as the schedule's do (:issue:`2060`). + Both ``conda-dist`` and the release ``conda`` job have a ``windows-latest`` leg, which builds, smoke-tests and publishes the ``win-64`` package (:issue:`2060`). It blocks as the other legs do: no step continues on error. Its one step that reads ``ANACONDA_TOKEN`` is ``Upload conda packages to Anaconda (Windows)``, the Intel leg's anaconda-client call (:issue:`2044`) under ``bash -el``, which skips a -manual run as every upload does. +build-only manual run as every upload does. :file:`tests/project/test_conda_windows_leg_2060.py` pins the leg. A required reviewer on ``conda-tag`` or ``anaconda`` would therefore hold every diff --git a/tests/project/test_build_only_dispatch.py b/tests/project/test_build_only_dispatch.py index 3eb5ffcfb1..afd0c80991 100644 --- a/tests/project/test_build_only_dispatch.py +++ b/tests/project/test_build_only_dispatch.py @@ -11,9 +11,12 @@ step that pushes, tags, uploads, deploys, posts or reads a secret is unreachable on ``workflow_dispatch``. Read conservatively: the step's own ``if:``, or its job's, has to hold ``github.event_name != 'workflow_dispatch'`` - as a top-level ``&&`` conjunct, which makes it false whatever else is true. - A job that still runs names no environment, and no run joins a concurrency - group a real run waits in. + as a top-level ``&&`` conjunct, which makes it false whatever else is true -- + or, in Conda Update, ``needs.mode.outputs.publishes == 'true'``, which its + ``mode`` job writes 'false' on a manual run without ``publish`` (#2060). A + job that still runs names no environment, and no run joins a concurrency + group a real run waits in. :mod:`tests.project.test_conda_publish_dispatch_2060` + covers a manual Conda Update run with ``publish``. * :class:`TestAManualRunStillBuilds`: the jobs and steps that build and test run on a manual run as they do on the schedule. * :class:`TestCreateReleaseIgnoresAManualVendorUpdate`: Create Release, which @@ -49,6 +52,12 @@ DISPATCH = 'workflow_dispatch' #: The guard each publishing step or job carries, whitespace removed. GUARD = "github.event_name!='workflow_dispatch'" +#: Conda Update's form of it (#2060): its `mode` job's verdict, which is 'false' +#: on a manual run without `publish`, as test_conda_publish_dispatch_2060.py runs it to show. +PUBLISH_GUARD = "needs.mode.outputs.publishes=='true'" +#: Each guard's own text, for the scan that strips them. +_GUARDS = re.compile(r"(github\.event_name != 'workflow_dispatch'|needs\.mode\.outputs\.publishes == 'true')" + r"(\s*&&\s*)?") #: Scheduled workflows with no manual run, and why none is needed. EXEMPT = { 'codeql-analysis.yml': 'runs on every push to main and every pull request', @@ -103,7 +112,7 @@ def conjuncts(condition: 'object') -> 'Optional[list[str]]': def guarded(entry: 'dict[str, Any]') -> 'bool': """Whether ``entry``'s ``if:`` is false on a manual run, whatever else holds.""" - return GUARD in (conjuncts(entry['if']) or []) if 'if' in entry else False + return bool({GUARD, PUBLISH_GUARD} & set(conjuncts(entry['if']) or [])) if 'if' in entry else False def publishes(entry: 'dict[str, Any]') -> 'bool': @@ -125,11 +134,32 @@ def exposed(workflow: 'dict[str, Any]') -> 'list[str]': return found +def mode_context(event_name: 'str') -> 'dict[str, Any]': + """``github`` and Conda Update's ``mode`` output on ``event_name``, ``publish`` unset.""" + publishes = 'false' if event_name == DISPATCH else 'true' + return {'github': {'event_name': event_name}, 'needs': {'mode': {'outputs': {'publishes': publishes}}}} + + +def job_group(group: 'str', event_name: 'str', run_id: 'str') -> 'str': + """A job's ``concurrency:`` group on ``event_name``, each ``${{ }}`` in it evaluated. + + ``format('{0}', github.run_id)`` is read as the string it makes, as + the evaluator models no ``format``. + + """ + context = mode_context(event_name) + context['github'] = {**context['github'], 'run_id': run_id, 'ref': 'refs/heads/main'} + group = re.sub(r"format\('([^'{]*)\{0\}',\s*github\.run_id\)", lambda match: f"'{match.group(1)}{run_id}'", + group) + return re.sub(r'\$\{\{(.*?)\}\}', lambda match: str(evaluate_expression('${{' + match.group(1) + '}}', context)), + group) + + def dispatch_environment(job: 'dict[str, Any]', event_name: 'str' = DISPATCH) -> 'object': value = job.get('environment', '') if isinstance(value, dict): value = value.get('name', '') - return evaluate_expression(value, {'github': {'event_name': event_name}}) if '${{' in value else value + return evaluate_expression(value, mode_context(event_name)) if '${{' in value else value class TestEveryScheduledWorkflowCanBeStarted(unittest.TestCase): @@ -171,7 +201,7 @@ def test_the_scan_sees_each_publisher(self) -> None: } for name in BUILD_ONLY: text = (WORKFLOWS / name).read_text(encoding='utf-8') - stripped = re.sub(r"github\.event_name != 'workflow_dispatch'(\s*&&\s*)?", 'true && ', text) + stripped = _GUARDS.sub('true && ', text) found = exposed(_workflows.loads(stripped)) with self.subTest(workflow=name): for publisher in expected[name]: @@ -185,6 +215,9 @@ def test_the_conjunct_reading_is_conservative(self) -> None: self.assertFalse(guarded({'if': "github.event_name != 'workflow_dispatch' || a"})) self.assertFalse(guarded({'if': "(github.event_name != 'workflow_dispatch' && a)"})) self.assertTrue(guarded({'if': "a && github.event_name != 'workflow_dispatch'"})) + self.assertTrue(guarded({'if': "a && needs.mode.outputs.publishes == 'true'"})) + self.assertFalse(guarded({'if': "needs.mode.outputs.publishes != 'true' && a"})) + self.assertFalse(guarded({'if': "needs.mode.outputs.publishes == 'true' || a"})) self.assertFalse(guarded({})) def test_a_running_job_names_no_environment(self) -> None: @@ -219,8 +252,9 @@ def group(event_name: 'str', run_id: 'str') -> 'str': if guarded(job) or not shared: continue with self.subTest(job=job_id): - self.assertIn(f"github.event_name == '{DISPATCH}' &&", shared) - self.assertIn('github.run_id', shared) + self.assertNotEqual(job_group(shared, DISPATCH, '1'), job_group(shared, DISPATCH, '2')) + self.assertEqual(job_group(shared, 'schedule', '1'), job_group(shared, 'schedule', '2')) + self.assertNotEqual(job_group(shared, DISPATCH, '1'), job_group(shared, 'schedule', '1')) def test_no_report_is_posted(self) -> None: for name in BUILD_ONLY: @@ -259,8 +293,9 @@ def test_conda_update_hands_conda_dist_a_version(self) -> None: version = next(step for step in _workflows.steps('cron-conda.yml', 'conda-update') if step.get('name') == 'Get Version') for event_name, expected in ((DISPATCH, True), ('schedule', False)): - context = {'github': {'event_name': event_name}, - 'steps': {'verify-changed-files': {'outputs': {'files_changed': 'false'}}}} + context = {**mode_context(event_name), + 'steps': {'verify-changed-files': {'outputs': {'files_changed': 'false'}}, + 'bump': {'outputs': {'bump': 'false'}}}} with self.subTest(event=event_name): self.assertIs(evaluate_condition(version['if'], context), expected) @@ -274,7 +309,7 @@ def test_conda_dist_builds_the_dispatched_commit_with_the_changes(self) -> None: name = step.get('id') or step.get('name') for os_label in _workflows.job('cron-conda.yml', 'conda-dist')['strategy']['matrix']['os']: for event_name in (DISPATCH, 'schedule'): - context = {'github': {'event_name': event_name}, 'matrix': {'os': os_label}, + context = {**mode_context(event_name), 'matrix': {'os': os_label}, 'steps': {'conda_build': {'outcome': 'success'}, 'release': {'outputs': {'published': 'true'}}}, STATUS: {'cancelled': False}} diff --git a/tests/project/test_conda_intel_cli_upload_2044.py b/tests/project/test_conda_intel_cli_upload_2044.py index 1e3d6a9946..1f7e0c7d1f 100644 --- a/tests/project/test_conda_intel_cli_upload_2044.py +++ b/tests/project/test_conda_intel_cli_upload_2044.py @@ -87,11 +87,13 @@ def legs(name: 'str', job_id: 'str') -> 'list[str]': def runs(entry: 'dict[str, Any]', os: 'str', published: 'str' = 'false') -> 'bool': """Whether ``entry`` runs on leg ``os``, with ``check_leg`` having found ``published``. - On the schedule: a manual run of cron-conda.yml uploads nothing on any leg, - which tests/project/test_build_only_dispatch.py pins. + On the schedule, where cron-conda.yml's `mode` says the run publishes: a + build-only manual run uploads nothing on any leg, which + tests/project/test_build_only_dispatch.py pins. """ context = {'matrix': {'os': os}, 'github': {'event_name': 'schedule'}, + 'needs': {'mode': {'outputs': {'publishes': 'true'}}}, 'steps': {'check_leg': {'outputs': {'PCAPKIT_LEG_EXISTS': published}}}} # type: dict[str, object] return evaluate_condition(str(entry.get('if', 'true')), context) diff --git a/tests/project/test_conda_publish_dispatch_2060.py b/tests/project/test_conda_publish_dispatch_2060.py new file mode 100644 index 0000000000..6c57dc6227 --- /dev/null +++ b/tests/project/test_conda_publish_dispatch_2060.py @@ -0,0 +1,492 @@ +# -*- coding: utf-8 -*- +"""#2060: a manual Conda Update run with ``publish`` bumps and publishes. + +The owner's ruling took the recommendation: a ``publish`` input on Conda +Update's manual run, accepted on ``main`` only, that enters the ``conda-tag`` +and ``anaconda`` environments and forces a build bump, so the run uploads every +platform -- 1.5.0b14's ``win-64`` and ``osx-64`` files among them -- even when +:file:`util/conda_vendor.py` regenerates the same recipe. Without it a manual +run stays build-only, which :mod:`tests.project.test_build_only_dispatch` pins. + +* :class:`TestThePublishInput`: the input is a boolean, ``false`` by default. +* :class:`TestTheModeJob`: ``mode`` is the one job reading the input. Its step, + run as ``bash`` runs it, writes ``publishes`` 'true' on the schedule, a + ``workflow_run`` and a manual run with ``publish`` on ``main``, and 'false' on + any other manual run; with ``publish`` on another ref it fails with an + ``::error``, and nothing after it runs. +* :class:`TestAPublishingManualRun`: on such a run every job and step that + pushes, tags, uploads or posts runs, as on the schedule, every + ``conda-dist`` leg uploads, each job enters its environment, and + ``conda-update`` joins ``repository-maintenance``; on a manual run without + ``publish`` none of them does. +* :class:`TestTheBumpIsForced`: ``Decide whether to bump`` says 'true' on such a + run although no file changed, and Bump Version, Commit Changes and Push + Changes follow it. +* :class:`TestAnUnpushedForcedBumpSaysSo` and + :class:`TestTheReportOfAnUnpushedForcedBump`: where Push Changes does not + push a forced bump, which no later run repeats, it warns that nothing was + published and to dispatch again, and the report says so too; every other + run's notice is as before. + +""" + +from __future__ import annotations + +import os +import pathlib +import re +import shutil +import subprocess # nosec: B404 +import tempfile +import unittest +from typing import TYPE_CHECKING + +from tests import _workflows +from tests._workflows import WORKFLOWS +from tests.project.test_build_only_dispatch import MARKERS +from tests.project.test_cron_reports_2030 import _ReportStep +from tests.project.test_release_gates import (_RUN_LITERAL, NULL, STATUS, CondaStepHarness, block_scalar_after, + conda_context, evaluate_condition, evaluate_expression, job_blocks, + job_runs, step_block) + +if TYPE_CHECKING: + from typing import Any + +#: Test tier (#1958): reads the workflow file, and runs its steps' shell offline. +__tier__ = 'LATEST' + +CONDA = 'cron-conda.yml' +DISPATCH = 'workflow_dispatch' +#: The ``bash`` the steps run under, by absolute path, as tests/test_tier_guard.py finds it (#2103). +BASH = shutil.which('bash') +#: Every step that publishes, by job, as the schedule runs them on some leg. +PUBLISHERS = { + 'conda-update': ['Load maintenance deploy key', 'Push Changes'], + 'conda-tag': ['Load maintenance deploy key', 'Checkout code', 'Push Tag'], + 'conda-dist': ['Upload conda packages to Anaconda', 'Upload conda packages to Anaconda (Intel)', + 'Upload conda packages to Anaconda (Windows)', 'Check the GitHub release exists', + 'Upload package to GitHub Release'], + 'discussion': ['Post the report to Discussions'], +} +#: The steps that bump, and the one that hands the build on. +BUMP_STEPS = ('Bump Version', 'Commit Changes', 'Push Changes', 'Get Version') +#: The steps only a build-only run takes, which hand ``conda-dist`` the unpushed tree. +BUILD_ONLY_STEPS = {'conda-update': ('Save the changes for conda-dist', 'Upload the changes for conda-dist'), + 'conda-dist': ('checkout-dispatched', 'download-changes', 'apply-changes')} + + +def name_of(entry: 'dict[str, Any]') -> 'str': + return str(entry.get('name') or entry.get('id') or entry.get('uses')) + + +def step(job_id: 'str', label: 'str') -> 'dict[str, Any]': + """The one step of ``job_id`` whose name, or else id, is ``label``.""" + [found] = [entry for entry in _workflows.steps(CONDA, job_id) if label in (entry.get('name'), entry.get('id'))] + return found + + +def context(event_name: 'str', publishes: 'object', os_label: 'str' = 'ubuntu-latest', + bump: 'str' = 'true') -> 'dict[str, Any]': + """A step's context: ``mode`` said ``publishes``, every earlier step succeeded, the release exists.""" + return {'github': {'event_name': event_name, 'ref': 'refs/heads/main', 'ref_name': 'main'}, + 'matrix': {'os': os_label}, + 'needs': {'mode': {'result': 'success' if publishes is not NULL else 'failure', + 'outputs': {} if publishes is NULL else {'publishes': publishes}}, + 'conda-update': {'result': 'success'}}, + 'steps': {'verify-changed-files': {'outputs': {'files_changed': 'false'}}, + 'bump': {'outputs': {'bump': bump}}, + 'release': {'outputs': {'published': 'true'}}, + 'conda_build': {'outcome': 'success'}}, + STATUS: {'cancelled': False}} + + +def step_runs(job_id: 'str', label: 'str', scope: 'dict[str, Any]') -> 'bool': + return evaluate_condition(str(step(job_id, label).get('if', 'true')), scope) + + +def legs() -> 'list[str]': + return list(_workflows.job(CONDA, 'conda-dist')['strategy']['matrix']['os']) + + +def environment(job_id: 'str', publishes: 'object') -> 'object': + value = _workflows.job(CONDA, job_id).get('environment', '') + return evaluate_expression(value, context(DISPATCH, publishes)) if '${{' in value else value + + +def manual_run(publishes: 'object', **changes: 'Any') -> 'dict[str, object]': + """:func:`conda_context` for a manual run whose ``mode`` wrote ``publishes``, nothing changed.""" + if publishes == 'true': + changes = {'bump': 'true', **changes} + else: + changes = {'pushed': NULL, 'conda_tag': 'skipped', **changes} + return conda_context(event_name=DISPATCH, unit_tests='success', verdict='skipped', verified=NULL, + publishes=publishes, files_changed='false', **changes) + + +class TestThePublishInput(unittest.TestCase): + + def test_it_is_a_boolean_off_by_default(self) -> None: + inputs = _workflows.load(CONDA)['on'][DISPATCH]['inputs'] + self.assertEqual(sorted(inputs), ['publish']) + publish = inputs['publish'] + self.assertEqual(publish['type'], 'boolean') + self.assertIs(publish['default'], False) + self.assertIs(publish['required'], False) + self.assertIn('main', publish['description']) + + def test_only_mode_reads_it(self) -> None: + """Every other job reads ``mode``'s verdict, so the input has one reading.""" + for job_id, job in _workflows.load(CONDA)['jobs'].items(): + with self.subTest(job=job_id): + self.assertEqual('inputs.' in repr(job), job_id == 'mode') + self.assertEqual(step('mode', 'Decide whether this run publishes')['env'], {'PUBLISH': '${{ inputs.publish }}'}) + + +@unittest.skipIf(not BASH, 'needs bash on PATH') +class TestTheModeJob(unittest.TestCase): + """``Decide whether this run publishes``, run in a scratch directory.""" + + def setUp(self) -> None: + self.tmp = pathlib.Path(tempfile.mkdtemp(prefix='pcapkit-2060-mode-')) + self.addCleanup(shutil.rmtree, self.tmp, True) + self.script = self.tmp / 'step.sh' + self.script.write_text(step('mode', 'Decide whether this run publishes')['run'], encoding='utf-8') + self.output = self.tmp / 'output' + + def decide(self, event_name: 'str', publish: 'str' = '', + ref: 'str' = 'refs/heads/main') -> 'tuple[int, str, str]': + """The step's exit status, what it wrote to ``GITHUB_OUTPUT``, and its stdout.""" + self.output.write_text('', encoding='utf-8') + env = {'PATH': os.environ.get('PATH', ''), 'GITHUB_OUTPUT': str(self.output), + 'GITHUB_EVENT_NAME': event_name, 'GITHUB_REF': ref, 'PUBLISH': publish} + result = subprocess.run([str(BASH), '-e', str(self.script)], cwd=self.tmp, env=env, # nosec: B603 + capture_output=True, text=True, check=False, timeout=60) + return result.returncode, self.output.read_text(encoding='utf-8'), result.stdout + + def test_every_other_event_publishes(self) -> None: + for event_name in ('schedule', 'workflow_run'): + with self.subTest(event=event_name): + self.assertEqual(self.decide(event_name)[:2], (0, 'publishes=true\n')) + + def test_a_manual_run_without_publish_builds_only(self) -> None: + """On any ref, so a branch's run is still build-only.""" + for publish in ('', 'false'): + for ref in ('refs/heads/main', 'refs/heads/feat/x'): + with self.subTest(publish=publish, ref=ref): + self.assertEqual(self.decide(DISPATCH, publish, ref)[:2], (0, 'publishes=false\n')) + + def test_a_manual_run_with_publish_on_main_publishes(self) -> None: + status, output, stdout = self.decide(DISPATCH, 'true') + self.assertEqual((status, output), (0, 'publishes=true\n')) + self.assertIn('::notice title=Publishing manual run::', stdout) + + def test_a_manual_run_with_publish_elsewhere_is_refused(self) -> None: + for ref in ('refs/heads/feat/2060', 'refs/tags/v1.5.0b14', 'refs/heads/mainline'): + with self.subTest(ref=ref): + status, output, stdout = self.decide(DISPATCH, 'true', ref) + self.assertEqual((status, output), (1, '')) + self.assertIn(f'::error title=publish is for main only::{ref} was dispatched', stdout) + + def test_a_refused_run_runs_nothing_after_it(self) -> None: + """``mode`` failed and wrote nothing, so ``conda-update`` skips and nothing posts.""" + jobs = job_blocks((WORKFLOWS / CONDA).read_text(encoding='utf-8')) + scope = conda_context(event_name=DISPATCH, unit_tests='success', verdict='skipped', verified=NULL, + mode='failure', conda_update='skipped', conda_tag='skipped', + conda_dist='skipped', outputs_present=False) + for job_id in ('conda-update', 'conda-tag', 'conda-dist', 'discussion'): + with self.subTest(job=job_id): + self.assertFalse(job_runs(jobs, job_id, scope)) + for job_id in ('conda-update', 'conda-dist'): + with self.subTest(job=job_id): + self.assertEqual(environment(job_id, NULL), '') + + def test_it_skips_only_a_pull_requests_run(self) -> None: + condition = _workflows.job(CONDA, 'mode')['if'] + for event_name, event, expected in (('schedule', None, True), (DISPATCH, None, True), + ('workflow_run', 'push', True), ('workflow_run', 'pull_request', False)): + scope = {'github': {'event_name': event_name, + 'event': {'workflow_run': {'event': event}} if event else {}}} + with self.subTest(event=event_name, workflow_run=event): + self.assertIs(evaluate_condition(condition, scope), expected) + self.assertEqual(_workflows.job(CONDA, 'mode')['permissions'], {}) + + +class TestAPublishingManualRun(unittest.TestCase): + """What a manual run with ``publish`` on ``main`` runs, against one without it.""" + + @classmethod + def setUpClass(cls) -> None: + cls.jobs = job_blocks((WORKFLOWS / CONDA).read_text(encoding='utf-8')) + + def test_the_known_publishers_are_the_scan(self) -> None: + """Not vacuous: :data:`PUBLISHERS` is every step the build-only scan calls publishing.""" + for job_id, labels in PUBLISHERS.items(): + found = [name_of(entry) for entry in _workflows.steps(CONDA, job_id) + if any(marker in repr(entry) for marker in MARKERS)] + with self.subTest(job=job_id): + self.assertEqual(found, labels) + + def test_every_publishing_job_runs(self) -> None: + for job_id in ('conda-update', 'conda-tag', 'conda-dist', 'discussion'): + with self.subTest(job=job_id): + self.assertTrue(job_runs(self.jobs, job_id, manual_run('true'))) + self.assertIs(job_runs(self.jobs, job_id, manual_run('false')), + job_id in ('conda-update', 'conda-dist')) + + def reachable(self, job_id: 'str', label: 'str', publishes: 'str') -> 'bool': + """Whether the step runs on some leg: its job runs, and then its own ``if:`` holds.""" + if not job_runs(self.jobs, job_id, manual_run(publishes)): + return False + return any(step_runs(job_id, label, context(DISPATCH, publishes, os_label)) for os_label in legs()) + + def test_every_publishing_step_runs(self) -> None: + for job_id, labels in PUBLISHERS.items(): + for label in labels: + with self.subTest(job=job_id, step=label): + self.assertTrue(self.reachable(job_id, label, 'true')) + self.assertFalse(self.reachable(job_id, label, 'false')) + + def test_every_leg_uploads(self) -> None: + """Intel and Windows included, which is what 1.5.0b14 lacks.""" + uploads = [label for label in PUBLISHERS['conda-dist'] if label.startswith('Upload conda packages')] + for os_label in legs(): + with self.subTest(os=os_label): + self.assertEqual(sum(step_runs('conda-dist', label, context(DISPATCH, 'true', os_label)) + for label in uploads), 1) + self.assertEqual(sum(step_runs('conda-dist', label, context(DISPATCH, 'false', os_label)) + for label in uploads), 0) + self.assertLessEqual({'macos-26-intel', 'windows-latest'}, set(legs())) + + def test_conda_dist_checks_out_the_tag(self) -> None: + """Not the dispatched commit with a patch, which only a build-only run hands on.""" + self.assertTrue(step_runs('conda-dist', 'checkout', context(DISPATCH, 'true'))) + for job_id, labels in BUILD_ONLY_STEPS.items(): + for label in labels: + with self.subTest(job=job_id, step=label): + self.assertFalse(step_runs(job_id, label, context(DISPATCH, 'true'))) + self.assertTrue(step_runs(job_id, label, context(DISPATCH, 'false'))) + self.assertFalse(step_runs(job_id, label, context('schedule', 'true'))) + + def test_each_job_enters_its_environment(self) -> None: + for job_id, name in (('conda-update', 'conda-tag'), ('conda-tag', 'conda-tag'), ('conda-dist', 'anaconda')): + with self.subTest(job=job_id): + self.assertEqual(environment(job_id, 'true'), name) + if job_id != 'conda-tag': + self.assertEqual(environment(job_id, 'false'), '') + + def test_conda_update_joins_the_write_serialisation(self) -> None: + group = _workflows.job(CONDA, 'conda-update')['concurrency']['group'] + group = re.sub(r"format\('([^'{]*)\{0\}',\s*github\.run_id\)", r"'\g<1>7'", group) + for publishes, expected in (('true', 'repository-maintenance'), ('false', 'repository-maintenance-7')): + with self.subTest(publishes=publishes): + self.assertEqual(evaluate_expression(group, context(DISPATCH, publishes)), expected) + # The same group as the schedule's, and Vendor Update's. + self.assertEqual(evaluate_expression(group, context('schedule', 'true')), 'repository-maintenance') + + def test_the_report_posts_as_the_schedule_does(self) -> None: + mode = step('discussion', 'Post the report to Discussions')['env']['MODE'] + self.assertEqual(evaluate_expression(mode, {'github': {'event_name': DISPATCH}}), 'scheduled') + + +class TestTheBumpIsForced(unittest.TestCase): + """A manual run with ``publish`` bumps when Verify Changed files saw nothing.""" + + def test_forced_on_a_publishing_manual_run_only(self) -> None: + forced = step('conda-update', 'Decide whether to bump')['env']['FORCED'] + for event_name, publishes, expected in ((DISPATCH, 'true', True), (DISPATCH, 'false', False), + ('schedule', 'true', False), ('workflow_run', 'true', False)): + with self.subTest(event=event_name, publishes=publishes): + self.assertIs(evaluate_expression(forced, context(event_name, publishes)), expected) + + def test_it_sits_between_the_check_and_the_bump(self) -> None: + names = [name_of(entry) for entry in _workflows.steps(CONDA, 'conda-update')] + self.assertEqual(names.index('Decide whether to bump'), names.index('Verify Changed files') + 1) + self.assertEqual(names.index('Bump Version'), names.index('Decide whether to bump') + 1) + self.assertEqual(step('conda-update', 'Decide whether to bump')['id'], 'bump') + + @unittest.skipIf(not BASH, 'needs bash on PATH') + def test_the_decision(self) -> None: + script = step('conda-update', 'Decide whether to bump')['run'] + with tempfile.TemporaryDirectory(prefix='pcapkit-2060-bump-') as tmp: + output = pathlib.Path(tmp) / 'output' + for files_changed, forced, expected in (('false', 'true', 'true'), ('false', 'false', 'false'), + ('true', 'false', 'true'), ('', 'false', 'false')): + output.write_text('', encoding='utf-8') + env = {'PATH': os.environ.get('PATH', ''), 'GITHUB_OUTPUT': str(output), + 'FILES_CHANGED': files_changed, 'FORCED': forced} + result = subprocess.run([str(BASH), '-e', '-c', script], cwd=tmp, env=env, # nosec: B603 + capture_output=True, text=True, check=False, timeout=60) + with self.subTest(files_changed=files_changed, forced=forced): + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output.read_text(encoding='utf-8'), f'bump={expected}\nforced={forced}\n') + + def test_the_bump_steps_follow_it(self) -> None: + for label in BUMP_STEPS: + with self.subTest(step=label): + self.assertTrue(step_runs('conda-update', label, context(DISPATCH, 'true', bump='true'))) + self.assertFalse(step_runs('conda-update', label, context('schedule', 'true', bump='false'))) + # A build-only run bumps on a change as before, and pushes nothing. + self.assertTrue(step_runs('conda-update', 'Bump Version', context(DISPATCH, 'false', bump='true'))) + self.assertFalse(step_runs('conda-update', 'Push Changes', context(DISPATCH, 'false', bump='true'))) + + def test_the_commit_says_why_nothing_else_changed(self) -> None: + self.assertIn('${CHANGED_FILES:-none, as a manual run with publish bumps regardless}', + step('conda-update', 'Commit Changes')['run']) + + def test_push_changes_and_the_report_are_told_it_was_forced(self) -> None: + """Through ``env:``, from the bump step's own ``forced``, not re-derived in the script.""" + for label in ('Push Changes', 'Write the run report'): + with self.subTest(step=label): + self.assertEqual(step('conda-update', label)['env']['FORCED'], '${{ steps.bump.outputs.forced }}') + + +#: Push Changes' three "not pushed" notices, and the warning each becomes on a forced bump. +MOVED_PAST = ('::notice title=Not pushed as main moved past this checkout::', + '::warning title=Nothing published as main moved past this checkout::') +MOVED_DURING = ('::notice title=Not pushed as main moved during the push::', + '::warning title=Nothing published as main moved during the push::') +ALREADY = ('::notice title=Build already on main::', + '::warning title=Nothing published as the build is already on main::') +#: What every forced warning says, and what a moved ``main``'s adds. +NOTHING = 'nothing was published' +AGAIN = 'dispatch Conda Update again with publish on main' + + +class TestAnUnpushedForcedBumpSaysSo(CondaStepHarness, unittest.TestCase): + """Push Changes, run against a local origin, on a forced bump it does not push. + + No later run repeats a forced bump, so the notice that leaves the bump to the + next Conda Update would be wrong: the run ends green having published + nothing. Each path warns instead and says to dispatch again; with no forced + bump, each notice is as before, byte for byte. + + """ + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.script = block_scalar_after(step_block(cls.jobs['conda-update'], 'Push Changes'), # type: ignore[attr-defined] + _RUN_LITERAL) + + def lines(self, stdout: 'str', prefix: 'str') -> 'list[str]': + return [line for line in stdout.splitlines() if line.startswith(prefix)] + + def moved_past(self, forced: 'str') -> 'tuple[str, str]': + """A merge with no verdict lands before the push: the step's stdout, and the merge.""" + clone = self._clone('first') + merge = self._land('merge', {'pcapkit/toolkit/scapy.py': 'fix\n'}, + 'fix(toolkit): a merge', (self.HUMAN, self.GITHUB)) + self._bump(clone, {}) + result, outputs = self._bash(self.script, clone, {'FORCED': forced}) # type: ignore[attr-defined] + self.assertEqual((result.returncode, outputs), (0, {'pushed': 'false'}), result.stderr) + return result.stdout, merge + + def moved_during(self, forced: 'str') -> 'tuple[str, str, str]': + """A merge lands after the fetch: the step's stdout, the merge, and the bump.""" + clone = self._clone('first') + merge, env = self._land_after_fetch('merge', {'pcapkit/toolkit/scapy.py': 'fix\n'}, + 'fix(toolkit): a merge', (self.HUMAN, self.GITHUB)) + self._bump(clone, {}) + result, outputs = self._bash(self.script, clone, {**env, 'FORCED': forced}) # type: ignore[attr-defined] + self.assertTrue((self.root / 'fired').exists(), 'the merge never raced the fetch') + self.assertEqual((result.returncode, outputs), (0, {'pushed': 'false'}), result.stderr) + return result.stdout, merge, self._git(clone, 'rev-parse', 'HEAD') + + def already(self, forced: 'str') -> 'str': + """Another run from the same checkout pushed the same build first: the step's stdout. + + On a forced bump this is two manual runs with ``publish`` dispatched on + one commit, or one beside a scheduled bump: the rebase drops the second. + + """ + first, second = self._clone('first'), self._clone('second') + for clone in (first, second): + self._bump(clone, {}) + self.assertEqual(self._bash(self.script, first, {})[1].get('pushed'), 'true') # type: ignore[attr-defined] + result, outputs = self._bash(self.script, second, {'FORCED': forced}) # type: ignore[attr-defined] + self.assertEqual((result.returncode, outputs), (0, {'pushed': 'false'}), result.stderr) + return result.stdout + + def test_moved_past_the_checkout_forced(self) -> None: + stdout, merge = self.moved_past('true') + self.assertEqual(self.lines(stdout, MOVED_PAST[0]), []) + [line] = self.lines(stdout, MOVED_PAST[1]) + self.assertIn(NOTHING, line) + self.assertTrue(line.endswith(f'{AGAIN}, now at {merge}.'), line) + + def assert_unforced_moved_past(self, forced: 'str') -> None: + stdout, merge = self.moved_past(forced) + self.assertEqual(self.lines(stdout, MOVED_PAST[1]), []) + [line] = self.lines(stdout, MOVED_PAST[0]) + self.assertTrue(line.endswith(f" The bump is left to the Conda Update that {merge}'s own Unit " + f'Tests run raises.'), line) + + def test_moved_past_the_checkout_unforced(self) -> None: + self.assert_unforced_moved_past('false') + + def test_moved_past_the_checkout_unset(self) -> None: + """An empty ``forced``, as a step that never ran leaves it, is not forced.""" + self.assert_unforced_moved_past('') + + def test_moved_during_the_push_forced(self) -> None: + stdout, merge, commit = self.moved_during('true') + self.assertEqual(self.lines(stdout, MOVED_DURING[0]), []) + self.assertEqual(self.lines(stdout, MOVED_DURING[1]), [ + f'{MOVED_DURING[1]}main moved from {self.base} to {merge} after the fetch, so {commit} is not a ' + f'fast-forward. No later run repeats this forced bump, so {NOTHING}: {AGAIN}, now at {merge}.']) + + def test_moved_during_the_push_unforced(self) -> None: + stdout, merge, commit = self.moved_during('false') + self.assertEqual(self.lines(stdout, MOVED_DURING[1]), []) + self.assertEqual(self.lines(stdout, MOVED_DURING[0]), [ + f'{MOVED_DURING[0]}main moved from {self.base} to {merge} after the fetch, so {commit} is not a ' + f"fast-forward. The bump is left to the Conda Update that {merge}'s own Unit Tests run raises."]) + + def test_already_on_main_forced(self) -> None: + """The run that pushed the build tags and uploads it, so dispatching again is only if it did not.""" + stdout = self.already('true') + self.assertEqual(self.lines(stdout, ALREADY[0]), []) + self.assertEqual(self.lines(stdout, ALREADY[1]), [ + f'{ALREADY[1]}conda/build 3 was pushed by another run, which tags and uploads it, so this run ' + f'published nothing; if that run did not publish it, {AGAIN}']) + + def test_already_on_main_unforced(self) -> None: + stdout = self.already('false') + self.assertEqual(self.lines(stdout, ALREADY[1]), []) + self.assertEqual(self.lines(stdout, ALREADY[0]), [ + f'{ALREADY[0]}conda/build 3 was pushed by another run; nothing to push, tag or upload']) + + +class TestTheReportOfAnUnpushedForcedBump(_ReportStep): + """Write the run report, on a forced bump Push Changes did not push: it says so, and warns.""" + + workflow, job, file = CONDA, 'conda-update', 'conda-report.md' + + def setUp(self) -> None: + super().setUp() + (self.work / 'conda').mkdir() + (self.work / 'conda' / 'build').write_text('4\n', encoding='utf-8') + (self.temp / 'platform-pins.log').write_text('', encoding='utf-8') + + def test_it_says_nothing_was_published(self) -> None: + text, status = self.report(PUBLISHES='true', FILES_CHANGED='false', BUMP='true', FORCED='true', + PUSH='success') + self.assertTrue(text.endswith("- Pushed: nothing, so this manual run with publish published nothing; " + "Push Changes' warning says why, and when to dispatch it again\n"), text) + self.assertEqual(status, 'warning') + + def test_a_pushed_forced_bump_is_a_success(self) -> None: + text, status = self.report(PUBLISHES='true', FILES_CHANGED='false', BUMP='true', FORCED='true', + PUSH='success', COMMIT='f' * 40) + self.assertTrue(text.endswith(f'- Pushed: build 4 as {"f" * 40}\n'), text) + self.assertEqual(status, 'success') + + def test_an_unforced_bump_not_pushed_is_as_before(self) -> None: + text, status = self.report(PUBLISHES='true', FILES_CHANGED='true', CHANGED_FILES='conda/build', + BUMP='true', FORCED='false', PUSH='success') + self.assertTrue(text.endswith("- Pushed: nothing; Push Changes' notice says why\n"), text) + self.assertEqual(status, 'success') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/project/test_conda_windows_leg_2060.py b/tests/project/test_conda_windows_leg_2060.py index 9664f8e8a8..8fa844c4e9 100644 --- a/tests/project/test_conda_windows_leg_2060.py +++ b/tests/project/test_conda_windows_leg_2060.py @@ -10,8 +10,8 @@ * :class:`TestTheWindowsLegPublishes`: on ``windows-latest`` each job runs one upload, "Upload conda packages to Anaconda (Windows)", evaluated with :func:`tests.project.test_release_gates.evaluate_condition`; it is the Intel - step's call in a login shell, and skips a manual run and a leg already on - Anaconda. :data:`release_published.SUBDIRS` counts the platform. + step's call in a login shell, and skips a build-only manual run and a leg + already on Anaconda. :data:`release_published.SUBDIRS` counts the platform. * :class:`TestTheWindowsLegBlocks`: no step continues on error and no ``Windows summary`` reports, so a failure fails the leg as it does the others; each ``run:`` the leg reaches names ``bash``. @@ -89,24 +89,28 @@ def step(name: 'str', job_id: 'str', step_name: 'str') -> 'dict[str, Any]': def context(job: 'dict[str, Any]', os_label: 'str', event_name: 'str' = 'schedule', - published: 'str' = 'false') -> 'dict[str, object]': + published: 'str' = 'false', publish: 'bool' = False) -> 'dict[str, object]': """A leg on ``os_label`` where every earlier step succeeded and the GitHub release exists. On the schedule unless ``event_name`` says otherwise: a manual run uploads - nothing on any leg, which :mod:`tests.project.test_build_only_dispatch` pins. - ``published`` is what the release job's ``check_leg`` found. + nothing on any leg, which :mod:`tests.project.test_build_only_dispatch` pins, + unless ``publish`` is set (#2060), which Conda Update's ``mode`` job turns + into ``publishes``. ``published`` is what the release job's ``check_leg`` + found. """ steps = {entry['id']: {'outcome': 'success', 'conclusion': 'success', 'outputs': {'published': 'true', 'PCAPKIT_LEG_EXISTS': published}} for entry in job['steps'] if 'id' in entry} + publishes = 'true' if publish or event_name != 'workflow_dispatch' else 'false' return {'matrix': {'os': os_label}, 'steps': steps, 'github': {'event_name': event_name}, + 'needs': {'mode': {'result': 'success', 'outputs': {'publishes': publishes}}}, STATUS: {'cancelled': False, 'success': True, 'failure': False}} def runs(job: 'dict[str, Any]', entry: 'dict[str, Any]', os_label: 'str', event_name: 'str' = 'schedule', - published: 'str' = 'false') -> 'bool': - return evaluate_condition(str(entry.get('if', 'true')), context(job, os_label, event_name, published)) + published: 'str' = 'false', publish: 'bool' = False) -> 'bool': + return evaluate_condition(str(entry.get('if', 'true')), context(job, os_label, event_name, published, publish)) def sensitive(entry: 'dict[str, Any]') -> 'bool': @@ -116,10 +120,10 @@ def sensitive(entry: 'dict[str, Any]') -> 'bool': def publishers(job: 'dict[str, Any]', os_label: 'str', event_name: 'str' = 'schedule', - published: 'str' = 'false') -> 'list[str]': + published: 'str' = 'false', publish: 'bool' = False) -> 'list[str]': """Each step of ``job`` that reads a secret or publishes, and would run on ``os_label``.""" return [str(entry.get('name') or entry.get('uses')) for entry in job['steps'] - if sensitive(entry) and runs(job, entry, os_label, event_name, published)] + if sensitive(entry) and runs(job, entry, os_label, event_name, published, publish)] class TestTheWindowsLegPublishes(unittest.TestCase): @@ -149,7 +153,12 @@ def test_no_other_leg_runs_it(self) -> None: def test_a_manual_conda_update_run_publishes_nothing_there(self) -> None: job = spec('cron-conda.yml', 'conda-dist') self.assertEqual(publishers(job, WINDOWS, event_name='workflow_dispatch'), []) - self.assertEqual(job['environment'], "${{ github.event_name != 'workflow_dispatch' && 'anaconda' || '' }}") + self.assertEqual(job['environment'], "${{ needs.mode.outputs.publishes == 'true' && 'anaconda' || '' }}") + + def test_a_manual_conda_update_run_with_publish_uploads_there(self) -> None: + """#2060: the manual run that gets 1.5.0b14's missing win-64 file onto Anaconda.""" + job = spec('cron-conda.yml', 'conda-dist') + self.assertEqual(publishers(job, WINDOWS, event_name='workflow_dispatch', publish=True), [WINDOWS_UPLOAD]) def test_a_leg_already_on_anaconda_skips_it(self) -> None: """#888's per-leg check gates the Windows upload as it does the others.""" diff --git a/tests/project/test_cron_reports_2030.py b/tests/project/test_cron_reports_2030.py index e4d97b152e..0cf289b4a5 100644 --- a/tests/project/test_cron_reports_2030.py +++ b/tests/project/test_cron_reports_2030.py @@ -50,8 +50,9 @@ ) #: Every job's own ``permissions:``, and the top-level block, as before #2030. PERMISSIONS = { - CONDA: {None: {'contents': 'read'}, 'head': {'contents': 'read'}, 'conda-update': {'contents': 'write'}, - 'conda-tag': {'contents': 'write'}, 'conda-dist': {'contents': 'write'}}, + CONDA: {None: {'contents': 'read'}, 'mode': {}, 'head': {'contents': 'read'}, + 'conda-update': {'contents': 'write'}, 'conda-tag': {'contents': 'write'}, + 'conda-dist': {'contents': 'write'}}, VERSIONS: {None: {}, 'detect': {'contents': 'read', 'pull-requests': 'read'}, 'pr': {'actions': 'read'}}, } #: The ``--mode`` and ``--status`` each workflow passes, and the step's ``env:`` @@ -71,9 +72,9 @@ }), } #: Each ``discussion`` job's ``needs:`` and ``if:``. -NEEDS = {CONDA: ['head', 'conda-update', 'conda-tag', 'conda-dist'], VERSIONS: ['detect']} +NEEDS = {CONDA: ['head', 'conda-update', 'conda-tag', 'conda-dist', 'mode'], VERSIONS: ['detect']} IFS = { - CONDA: "${{ always() && github.event_name != 'workflow_dispatch' && (needs.conda-update.result != 'skipped' || " + CONDA: "${{ always() && needs.mode.outputs.publishes == 'true' && (needs.conda-update.result != 'skipped' || " "needs.head.result == 'failure' || " "needs.head.result == 'cancelled') }}", VERSIONS: "${{ always() && needs.detect.result != 'skipped' }}", @@ -157,10 +158,11 @@ def test_each_workflow_posts_its_report_with_the_script(self) -> None: def test_the_mode_follows_the_event(self) -> None: """Python Versions is always ``scheduled``; Conda Update's ``workflow_run`` follows a push, so ``push``.""" self.assertEqual(sorted(load(VERSIONS)['on']), ['schedule', 'workflow_dispatch']) - # A manual run posts nothing; tests/project/test_build_only_dispatch.py pins it. + # A manual run posts nothing unless `publish` is set, and then as the + # schedule does (#2060); tests/project/test_build_only_dispatch.py pins the first. self.assertEqual(sorted(load(CONDA)['on']), ['schedule', 'workflow_dispatch', 'workflow_run']) expression = step(CONDA, 'discussion', 'Post the report to Discussions')['env']['MODE'] - for event, mode in (('schedule', 'scheduled'), ('workflow_run', 'push')): + for event, mode in (('schedule', 'scheduled'), ('workflow_run', 'push'), ('workflow_dispatch', 'scheduled')): with self.subTest(event=event): self.assertEqual(evaluate(expression, {'github': {'event_name': event}}), mode) @@ -192,23 +194,32 @@ def test_no_other_job_runs_the_script(self) -> None: def test_the_conda_report_runs_on_a_failed_gate_but_not_a_deliberate_skip(self) -> None: """As cron-vendor's: a pull request's ``workflow_run``, or a verdict for another commit, posts nothing.""" jobs = job_blocks((WORKFLOWS / CONDA).read_text(encoding='utf-8')) - # `(event, head, conda-update, whether discussion runs)`; tag and upload skip. + # `(event, head, conda-update, whether discussion runs, mode's `publishes`)`; + # tag and upload skip. #2060: a manual run posts only with `publish`, + # and one `mode` refused, which wrote no `publishes`, posts nothing. rows = ( - ('schedule', 'success', 'success', True), - ('schedule', 'success', 'failure', True), - ('schedule', 'failure', 'skipped', True), - ('schedule', 'cancelled', 'skipped', True), - ('workflow_run', 'success', 'success', True), - ('workflow_run', 'failure', 'skipped', True), - ('workflow_run', 'success', 'skipped', False), - ('workflow_run', 'skipped', 'skipped', False), + ('schedule', 'success', 'success', True, 'true'), + ('schedule', 'success', 'failure', True, 'true'), + ('schedule', 'failure', 'skipped', True, 'true'), + ('schedule', 'cancelled', 'skipped', True, 'true'), + ('workflow_run', 'success', 'success', True, 'true'), + ('workflow_run', 'failure', 'skipped', True, 'true'), + ('workflow_run', 'success', 'skipped', False, 'true'), + ('workflow_run', 'skipped', 'skipped', False, 'true'), + ('workflow_dispatch', 'success', 'success', False, 'false'), + ('workflow_dispatch', 'failure', 'skipped', False, 'false'), + ('workflow_dispatch', 'success', 'success', True, 'true'), + ('workflow_dispatch', 'failure', 'skipped', True, 'true'), + ('workflow_dispatch', 'success', 'skipped', False, None), ) - for event, head, update, expected in rows: + for event, head, update, expected, publishes in rows: needs = {'head': {'result': head}, 'conda-update': {'result': update, 'outputs': {}}, - 'conda-tag': {'result': 'skipped'}, 'conda-dist': {'result': 'skipped'}} + 'conda-tag': {'result': 'skipped'}, 'conda-dist': {'result': 'skipped'}, + 'mode': {'result': 'failure' if publishes is None else 'success', + 'outputs': {} if publishes is None else {'publishes': publishes}}} context = {STATUS: {'cancelled': False}, 'github': {'event_name': event, 'event': {}}, 'needs': needs} # type: dict[str, Any] - with self.subTest(event=event, head=head, conda_update=update): + with self.subTest(event=event, head=head, conda_update=update, publishes=publishes): self.assertEqual(job_runs(jobs, 'discussion', context), expected) [download] = [entry for entry in load(CONDA)['jobs']['discussion']['steps'] if pinned(entry.get('uses'), 'actions/download-artifact')] @@ -314,6 +325,28 @@ def setUp(self) -> None: def pins(self, *lines: 'str') -> 'None': (self.temp / 'platform-pins.log').write_text(''.join(f'{line}\n' for line in lines), encoding='utf-8') + def report(self, **env: 'str') -> 'tuple[str, str]': + """As a run that publishes, the schedule's or a ``workflow_run``'s, unless ``env`` says otherwise.""" + return super().report(**{'PUBLISHES': 'true', **env}) + + def test_a_build_only_run(self) -> None: + """A manual run without ``publish``, for which ``mode`` wrote 'false'.""" + self.pins() + for files_changed in ('true', 'false'): + with self.subTest(files_changed=files_changed): + text, _ = self.report(PUBLISHES='false', FILES_CHANGED=files_changed, BUMP=files_changed, + CHANGED_FILES='conda/requirements.txt', PUSH='skipped') + self.assertTrue(text.endswith('- Pushed: nothing, as a manual run only builds\n'), text) + + def test_a_forced_bump(self) -> None: + """#2060: a manual run with ``publish``, bumped and pushed although nothing changed.""" + self.pins('pcapkit/_extern: 12 distributions') + sha = 'f4f94bcad' + '0' * 31 + self.assertEqual(self.report(FILES_CHANGED='false', BUMP='true', PUSH='success', COMMIT=sha), ( + '### Conda Update\n\n- Platform pins: none moved, and none warned\n' + '- Changed: nothing, and the build was bumped anyway, as a manual run with publish asks\n' + f'- Pushed: build 4 as {sha}\n', 'success')) + def test_a_quiet_run(self) -> None: self.pins('pcapkit/_extern: 12 distributions') self.assertEqual(self.report(FILES_CHANGED='false', PUSH='skipped'), ( diff --git a/tests/project/test_release_gates.py b/tests/project/test_release_gates.py index 3ce893a24e..e555fb82cf 100644 --- a/tests/project/test_release_gates.py +++ b/tests/project/test_release_gates.py @@ -1628,8 +1628,9 @@ def job_runs(jobs: 'dict[str, str]', name: 'str', context: 'dict[str, object]') #: The jobs :file:`cron-conda.yml` declares. Named so that a scenario row can #: be keyed on them and ``test_the_scan_found_the_jobs`` fails loudly when the #: graph is renamed or extended, rather than leaving a new job unchecked. -#: ``head`` is the call to :data:`HEAD_WORKFLOW`, and has no ``if:`` of its own. -CONDA_JOBS = ('head', 'conda-update', 'conda-tag', 'conda-dist') +#: ``head`` is the call to :data:`HEAD_WORKFLOW`, and has no ``if:`` of its own; +#: ``mode`` says whether the run publishes (#2060). +CONDA_JOBS = ('head', 'mode', 'conda-update', 'conda-tag', 'conda-dist') #: The job posting the run's report (#2030). It gates nothing, so no row is #: keyed on it; :file:`tests/project/test_cron_reports_2030.py` pins it. CONDA_REPORTER = 'discussion' @@ -1675,7 +1676,10 @@ def conda_context(unit_tests: 'str' = 'skipped', workflow_run_event: 'str' = 'push', head_branch: 'str' = 'main', cancelled: 'bool' = False, - outputs_present: 'bool' = True) -> 'dict[str, object]': + outputs_present: 'bool' = True, + mode: 'Optional[str]' = None, + publishes: 'object' = None, + bump: 'object' = None) -> 'dict[str, object]': """The ``needs``/``github``/status context one row of :data:`CONDA_ROWS` stands for. **The defaults are run 38325136710**, the broken run in #1632: a @@ -1702,18 +1706,32 @@ def conda_context(unit_tests: 'str' = 'skipped', ``outputs_present=False`` is the shape a ``conda-update`` that never ran leaves behind: no step wrote anything, which Actions serves as ``null`` rather than as ``''``. ``pushed=NULL`` is the narrower shape where Push - Changes did not write it -- and the only one allowed when ``files_changed`` - is not ``'true'``, since that step is skipped then. + Changes did not write it -- and the only one allowed when ``bump`` is not + ``'true'``, since that step is skipped then. ``commit`` is the sha Push Changes writes beside ``pushed=true`` and only then; left out, it is :data:`PUSHED_COMMIT` when ``pushed`` is ``'true'`` and ``NULL`` otherwise. + ``mode``, ``publishes`` and ``bump`` are #2060's. ``mode`` is the result of + the job of that name, which skips a pull request's Unit Tests run and + succeeds otherwise unless a row says so; ``publishes`` its output, left out + 'true' but on ``workflow_dispatch``, where it is 'false' -- a manual run + without ``publish`` -- and :data:`NULL` when ``mode`` did not succeed. + ``bump`` is Decide whether to bump's output, ``files_changed`` when left + out; a manual run with ``publish`` sets it 'true' over a 'false' one. + """ - if files_changed != 'true' and pushed is not NULL: + if mode is None: + mode = 'skipped' if event_name == 'workflow_run' and workflow_run_event != 'push' else 'success' + if publishes is None: + publishes = NULL if mode != 'success' else 'false' if event_name == 'workflow_dispatch' else 'true' + if bump is None: + bump = files_changed + if bump != 'true' and pushed is not NULL: raise AssertionError( - f'Push Changes is skipped unless files_changed is \'true\', so it cannot ' - f'have written pushed={pushed!r} with files_changed={files_changed!r}' + f'Push Changes is skipped unless bump is \'true\', so it cannot ' + f'have written pushed={pushed!r} with bump={bump!r}' ) if commit is None: commit = PUSHED_COMMIT if pushed == 'true' else NULL @@ -1727,6 +1745,7 @@ def conda_context(unit_tests: 'str' = 'skipped', if outputs_present: steps = { 'verify-changed-files': {'outputs': {'files_changed': files_changed}}, + 'bump': {'outputs': {'bump': bump}}, 'push-changes': {'outputs': {'pushed': pushed, 'commit': commit}}, # The values runs 38325136710 and 38325153882 both produced, for # the same reason the results above are a real run's: a row reads @@ -1745,6 +1764,7 @@ def conda_context(unit_tests: 'str' = 'skipped', 'outputs': {} if verified is NULL else {'verified': verified}, 'jobs': {'unit-tests': {'result': unit_tests}, 'verdict': {'result': verdict}}, }, + 'mode': {'result': mode, 'outputs': {} if publishes is NULL else {'publishes': publishes}}, 'conda-update': {'result': conda_update, 'steps': steps}, 'conda-tag': {'result': conda_tag}, 'conda-dist': {'result': conda_dist}, @@ -1822,7 +1842,7 @@ def conda_context(unit_tests: 'str' = 'skipped', {'verdict': True, 'conda-update': True, 'conda-tag': True, 'conda-dist': True}), ('the Saturday schedule, the gate green', conda_context(event_name='schedule', unit_tests='success', verdict='skipped', verified=NULL), - {'unit-tests': True, 'verdict': False, 'conda-update': True, 'conda-tag': True, + {'unit-tests': True, 'verdict': False, 'mode': True, 'conda-update': True, 'conda-tag': True, 'conda-dist': True}), ('the Saturday schedule, the gate failed', conda_context(event_name='schedule', unit_tests='failure', verdict='skipped', verified=NULL, @@ -1869,7 +1889,8 @@ def conda_context(unit_tests: 'str' = 'skipped', conda_context(workflow_run_event='pull_request', verdict='skipped', verified=NULL, conda_update='skipped', conda_tag='skipped', conda_dist='skipped', outputs_present=False), - {'verdict': False, 'conda-update': False, 'conda-tag': False, 'conda-dist': False}), + {'verdict': False, 'mode': False, 'conda-update': False, 'conda-tag': False, + 'conda-dist': False}), ('Unit Tests failed on main', conda_context(workflow_run_conclusion='failure', verdict='skipped', verified=NULL, conda_update='skipped', conda_tag='skipped', conda_dist='skipped', @@ -1880,6 +1901,25 @@ def conda_context(unit_tests: 'str' = 'skipped', conda_update='skipped', conda_tag='skipped', conda_dist='skipped', outputs_present=False), {'verdict': False, 'conda-update': False, 'conda-tag': False, 'conda-dist': False}), + # #2060: a manual run. Without `publish` it builds what `conda-update` + # left and tags nothing, as before; with it from `main`, the build is + # bumped although nothing changed, then tagged and uploaded as the + # schedule's is; with it from another ref, `mode` fails and nothing runs. + ('a manual run without publish, nothing changed', + conda_context(event_name='workflow_dispatch', unit_tests='success', verdict='skipped', + verified=NULL, files_changed='false', pushed=NULL, conda_tag='skipped'), + {'unit-tests': True, 'verdict': False, 'mode': True, 'conda-update': True, + 'conda-tag': False, 'conda-dist': True}), + ('a manual run with publish on main, nothing changed, so the bump is forced', + conda_context(event_name='workflow_dispatch', unit_tests='success', verdict='skipped', + verified=NULL, publishes='true', files_changed='false', bump='true'), + {'unit-tests': True, 'mode': True, 'conda-update': True, 'conda-tag': True, + 'conda-dist': True}), + ('a manual run with publish off main, which mode refuses', + conda_context(event_name='workflow_dispatch', unit_tests='success', verdict='skipped', + verified=NULL, mode='failure', conda_update='skipped', conda_tag='skipped', + conda_dist='skipped', outputs_present=False), + {'conda-update': False, 'conda-tag': False, 'conda-dist': False}), ] @@ -4019,15 +4059,16 @@ def test_the_publishing_jobs_name_their_predecessors_strictly(self) -> None: Here ``conda-tag`` skips only when no bump was committed, in which case there is nothing for ``conda-dist`` to upload either. - ``conda-dist``'s tag and bump clauses sit behind the manual run's escape, - the last conjunct, which uploads nothing (see + ``conda-dist``'s tag and bump clauses sit behind the build-only run's + escape, the last conjunct, which uploads nothing (see :mod:`tests.project.test_build_only_dispatch`); they are checked as the - conjuncts they are on every other event. + conjuncts they are on every run that publishes. Each also needs + ``mode``, for its ``publishes`` output rather than its result (#2060). """ predecessors = {'conda-tag': ('conda-update',), 'conda-dist': ('conda-update', 'conda-tag')} - escape = "&&(github.event_name=='workflow_dispatch'||(" + escape = "&&(needs.mode.outputs.publishes!='true'||(" for job, required in sorted(predecessors.items()): with self.subTest(job=job): condition = declared_if(self.jobs[job]) @@ -4049,7 +4090,7 @@ def test_the_publishing_jobs_name_their_predecessors_strictly(self) -> None: f'cancelled run', ) self.assertEqual( - set(declared_needs(self.jobs[job])), set(required), + set(declared_needs(self.jobs[job])), {*required, 'mode'}, f'`{job}`\'s `needs:` and the clauses asserted here disagree, ' f'so at least one predecessor is not being checked', ) diff --git a/tests/project/test_release_tags_the_run_sha_1634.py b/tests/project/test_release_tags_the_run_sha_1634.py index 57cac71830..699cd4e542 100644 --- a/tests/project/test_release_tags_the_run_sha_1634.py +++ b/tests/project/test_release_tags_the_run_sha_1634.py @@ -1679,7 +1679,7 @@ def runs(self, outputs: 'dict[str, str]', runner: 'str' = 'ubuntu-latest') -> 'b """Whether the action's ``if:`` lets it run, given the release check's ``outputs``.""" return evaluate_condition(self.upload()['if'], { 'matrix': {'os': runner}, 'steps': {'release': {'outputs': outputs}}, - 'github': {'event_name': 'schedule'}, + 'github': {'event_name': 'schedule'}, 'needs': {'mode': {'outputs': {'publishes': 'true'}}}, }) @@ -1728,7 +1728,7 @@ def test_the_check_reads_the_release_the_action_updates(self) -> None: self.assertEqual(self.index('conda-dist', RELEASE_CHECK_STEP) + 1, self.index('conda-dist', UPLOAD_STEP)) self.assertEqual(check.get('id'), 'release') self.assertEqual(check.get('if'), - "${{ github.event_name != 'workflow_dispatch' && matrix.os == 'ubuntu-latest' }}") + "${{ needs.mode.outputs.publishes == 'true' && matrix.os == 'ubuntu-latest' }}") self.assertEqual(check.get('shell'), 'bash') self.assertEqual(check.get('env'), {'GH_TOKEN': '${{ secrets.GITHUB_TOKEN }}', 'PCAPKIT_RELEASE_TAG': CONDA_RELEASE_TAG}) @@ -1736,14 +1736,14 @@ def test_the_check_reads_the_release_the_action_updates(self) -> None: def test_the_anaconda_upload_is_not_gated_on_it(self) -> None: # #2044, #2060: gated on the leg alone, Intel and Windows with their own steps. - # A manual run uploads nothing (tests/project/test_build_only_dispatch.py). + # A build-only run uploads nothing (tests/project/test_build_only_dispatch.py). self.assertEqual(self.step('conda-dist', 'Upload conda packages to Anaconda').get('if'), - "${{ github.event_name != 'workflow_dispatch' && " + "${{ needs.mode.outputs.publishes == 'true' && " "matrix.os != 'macos-26-intel' && matrix.os != 'windows-latest' }}") self.assertEqual(self.step('conda-dist', 'Upload conda packages to Anaconda (Intel)').get('if'), - "${{ github.event_name != 'workflow_dispatch' && matrix.os == 'macos-26-intel' }}") + "${{ needs.mode.outputs.publishes == 'true' && matrix.os == 'macos-26-intel' }}") self.assertEqual(self.step('conda-dist', 'Upload conda packages to Anaconda (Windows)').get('if'), - "${{ github.event_name != 'workflow_dispatch' && matrix.os == 'windows-latest' }}") + "${{ needs.mode.outputs.publishes == 'true' && matrix.os == 'windows-latest' }}") def test_an_update_targets_the_default_branch(self) -> None: """``github.ref_name`` is the default branch on this workflow's triggers. @@ -1751,7 +1751,8 @@ def test_an_update_targets_the_default_branch(self) -> None: "Events that trigger workflows" gives ``GITHUB_REF`` as "Default branch" for both ``schedule`` and ``workflow_run``, so the triggers are pinned too: another one could name another ref. ``workflow_dispatch`` does, the - dispatched branch, so the upload never runs on it. + dispatched branch, so the upload runs on it only with ``publish``, which + ``mode`` refuses on any branch but ``main`` (#2060). """ self.assertEqual(self.upload()['with'].get('commit'), DEFAULT_BRANCH) lines = CONDA_WORKFLOW.read_text(encoding='utf-8').splitlines() @@ -1759,10 +1760,13 @@ def test_an_update_targets_the_default_branch(self) -> None: end = next(i for i in range(start, len(lines)) if lines[i] and not lines[i].startswith((' ', '#'))) triggers = [m.group(1) for line in lines[start:end] for m in [re.match(r'^ ([a-z_]+):', line)] if m] self.assertEqual(triggers, ['schedule', 'workflow_run', 'workflow_dispatch']) - self.assertFalse(evaluate_condition(self.upload()['if'], { - 'matrix': {'os': 'ubuntu-latest'}, 'steps': {'release': {'outputs': {'published': 'true'}}}, - 'github': {'event_name': 'workflow_dispatch'}, - })) + for publishes in ('false', 'true'): + with self.subTest(publishes=publishes): + self.assertIs(evaluate_condition(self.upload()['if'], { + 'matrix': {'os': 'ubuntu-latest'}, 'steps': {'release': {'outputs': {'published': 'true'}}}, + 'github': {'event_name': 'workflow_dispatch'}, + 'needs': {'mode': {'outputs': {'publishes': publishes}}}, + }), publishes == 'true') #: A ``gh`` failing as it does without a token, for the check's other branch. diff --git a/tests/project/test_secret_environments_1968.py b/tests/project/test_secret_environments_1968.py index 648a5f9fe7..a90e6fedc3 100644 --- a/tests/project/test_secret_environments_1968.py +++ b/tests/project/test_secret_environments_1968.py @@ -14,7 +14,9 @@ A manual run (``workflow_dispatch``) builds only and names no environment, so a job may name its environment through an expression; :func:`environment` reads the name it gives every other event, where the secrets are read. -:mod:`tests.project.test_build_only_dispatch` pins the manual run's side. +:mod:`tests.project.test_build_only_dispatch` pins the manual run's side. In +Conda Update the expression reads its ``mode`` job's ``publishes``, which is +'true' on every other event and on a manual run with ``publish`` (#2060). """ @@ -40,13 +42,21 @@ SECRET = re.compile(r'\bsecrets\.(\w+)') -def environment(job: 'dict[str, Any]', event_name: 'str' = 'schedule') -> 'Optional[str]': - """The environment ``job`` names on ``event_name``, in either spelling, or :data:`None`.""" +def environment(job: 'dict[str, Any]', event_name: 'str' = 'schedule', + publish: 'bool' = False) -> 'Optional[str]': + """The environment ``job`` names on ``event_name``, in either spelling, or :data:`None`. + + ``publish`` is a manual run's input of that name, as Conda Update's ``mode`` + job reads it from ``main``. + + """ value = job.get('environment') if isinstance(value, dict): value = value.get('name') if isinstance(value, str) and value.startswith('${{'): - value = str(evaluate_expression(value, {'github': {'event_name': event_name}})) + publishes = 'true' if publish or event_name != 'workflow_dispatch' else 'false' + value = str(evaluate_expression(value, {'github': {'event_name': event_name}, + 'needs': {'mode': {'outputs': {'publishes': publishes}}}})) return value @@ -97,6 +107,10 @@ def test_an_expression_is_read_per_event(self) -> None: job = {'environment': "${{ github.event_name != 'workflow_dispatch' && 'anaconda' || '' }}"} self.assertEqual(environment(job), 'anaconda') self.assertEqual(environment(job, 'workflow_dispatch'), '') + job = {'environment': "${{ needs.mode.outputs.publishes == 'true' && 'anaconda' || '' }}"} + self.assertEqual(environment(job), 'anaconda') + self.assertEqual(environment(job, 'workflow_dispatch'), '') + self.assertEqual(environment(job, 'workflow_dispatch', publish=True), 'anaconda') if __name__ == '__main__': diff --git a/tests/project/test_workflow_run_verdict.py b/tests/project/test_workflow_run_verdict.py index 271d0f076c..a3e1e49a6a 100644 --- a/tests/project/test_workflow_run_verdict.py +++ b/tests/project/test_workflow_run_verdict.py @@ -451,9 +451,13 @@ def _context(event_name: 'str' = 'workflow_run', conclusion: 'str' = 'success', if event_name == 'workflow_run': github['event'] = {'workflow_run': {'conclusion': conclusion, 'event': event, 'head_branch': head_branch, 'head_sha': head_sha}} + # cron-conda.yml's `mode` (#2060), which no other caller has: it skips a pull + # request's Unit Tests run and says every other event here publishes. + mode = ({'result': 'skipped', 'outputs': {}} if event_name == 'workflow_run' and event != 'push' + else {'result': 'success', 'outputs': {'publishes': 'true'}}) return { 'github': github, - 'needs': {'head': {'result': head_result(unit_tests, verdict), 'outputs': outputs}}, + 'needs': {'head': {'result': head_result(unit_tests, verdict), 'outputs': outputs}, 'mode': mode}, STATUS: {'cancelled': cancelled}, } @@ -518,9 +522,11 @@ def _jobs(self, workflow: 'str') -> 'dict[str, str]': return jobs def test_each_acting_job_needs_the_head(self) -> None: + """And, in Conda Update, `mode`, which says whether the run publishes (#2060).""" for workflow, job in ACTING_JOBS.items(): with self.subTest(workflow=workflow): - self.assertEqual(declared_needs(self._jobs(workflow)[job]), ['head']) + self.assertEqual(declared_needs(self._jobs(workflow)[job]), + ['head', 'mode'] if workflow == 'cron-conda.yml' else ['head']) def test_every_scenario_decides_correctly(self) -> None: """The table above, for :data:`HEAD`'s jobs and each acting job."""