diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index cd67efaed..1788b3237 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -69,9 +69,11 @@ concurrency: # an unintended publish -- a version number it has accepted cannot be reused, and # ``skip-existing: true`` below means a re-upload then *succeeds* having published # nothing, so the number the intended release wanted is gone regardless. ``github`` -# and ``tag`` were split on the same principle: ``github`` spends the run's own -# ``GITHUB_TOKEN``, whereas ``tag`` spends a long-lived PAT and a deploy key to -# write to the default branch. +# and ``tag`` were split on the same principle: since #1634 ``github`` spends a +# token of the release App, minted in the job from ``github-release``'s secret +# and revoked when it ends (the run's ``GITHUB_TOKEN`` while no App is set up), +# whereas ``tag`` spends a long-lived PAT and a deploy key to write to the +# default branch. # # Two things about this that are not self-enforcing, and #641 is the first of them: # @@ -125,8 +127,8 @@ concurrency: # ``!= 'failure'``: the two spellings differ only when a predecessor's own # result is ``cancelled``, which ``!= 'failure'`` still admits and the equality # pair does not. A cancelled ``tag`` would otherwise send ``conda`` into its -# ``actions/checkout`` with ``ref: conda-+0`` for a tag ``tag`` never -# pushed, trading a clean skip for a checkout failure. ``version_check`` is held +# check that ``conda-+0`` names this run's commit, for a tag ``tag`` +# never pushed, trading a clean skip for a failed step. ``version_check`` is held # to a stricter bar still: all three require ``needs.version_check.result == # 'success'`` with no ``|| 'skipped'``, since it produces the evidence the gates # below read, and a skipped or cancelled ``version_check`` leaves them nothing @@ -166,6 +168,27 @@ concurrency: # that did not happen, reported by ``release_status``, never a release that # went out untested. ``tests/project/test_release_gates.py`` asserts both # halves. +# +# #1634: every job that checks out, tags, builds or publishes pins +# ``github.sha``, the commit this run was created for, never a ref that moves +# while the run waits on the ``github-release`` approval. On a ``v*`` push that +# is the pushed tag's commit; on ``workflow_run`` it is ``main``'s tip when the +# run was created, *not* ``github.event.workflow_run.head_sha``, which is the +# commit Vendor Update ran on and so predates the bump it pushed (run +# 38008819343: ``github.sha`` ``b6b95bc8e``, Vendor Update run 38008512299 on +# ``945c3c3d8``). That run was approved three days later and tagged ``main``'s +# tip ``2c0008c3c`` as both ``v1.5.0b9`` and ``conda-1.5.0b9+0``: +# ``action-gh-release`` defaulted ``target_commitish`` to the default branch, +# and ``tag`` ran ``git pull --rebase origin main`` before tagging. ``github``, +# ``tag``, ``pypi`` and ``conda`` also refuse to publish under a tag naming any +# other commit, so a later run for the same version fails loudly instead of +# putting a second tree out under it; finishing a release means re-running the +# run that tagged it. ``github`` creates the ``v*`` tag with the release App's +# token once ``vars.RELEASE_APP_CLIENT_ID`` is set, since GitHub refuses +# ``GITHUB_TOKEN`` a target that differs from ``main`` in a workflow file, and +# ``version_check`` skips the tag-push run that tag starts. +# ``tests/project/test_release_tags_the_run_sha_1634.py`` asserts all of this +# and runs ``tag``'s scripts against a scratch repository. jobs: version_check: @@ -176,13 +199,24 @@ jobs: # triggered it concluded ``success``, and a tag push always proceeds. This # job is read-only -- it installs the package and reads three public # endpoints -- so running it ahead of the test gate risks nothing. - if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + # + # #1634: a ``v*`` push by the release App's bot skips too. That is the tag + # ``github``'s Create Release makes with the App's token, which, unlike + # ``GITHUB_TOKEN``'s, starts a run; the run that made it releases that + # commit. Without this the second run would rerun the gate, ask for a + # second approval, and race the first run's uploads from another + # concurrency group. Whether that push fires, and carries the bot's + # ``github.actor_id``, is unmeasured; if not, this never matches. + if: >- + ${{ (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') && + !(github.event_name == 'push' && github.actor_id == vars.RELEASE_APP_BOT_ID) }} permissions: contents: read steps: - name: Checkout code uses: actions/checkout@v7 with: + ref: ${{ github.sha }} fetch-depth: 0 - uses: actions/setup-python@v7 @@ -383,14 +417,17 @@ jobs: github: name: GitHub Release runs-on: ubuntu-latest - # Gated: publishes the Release, and creates the ``v*`` tag that every job - # below then checks out. Since #887 this is the release's only approval -- + # Gated: publishes the Release, and creates the ``v*`` tag -- at + # ``github.sha`` since #1634 -- that every job below then verifies before + # publishing under it. Since #887 this is the release's only approval -- # see the note above ``jobs:``. Still gated on ``PCAPKIT_TAG_EXISTS``, # unlike the three jobs below it: the tag that output answers for is this # job's own artefact, not a proxy for someone else's -- see the #888 note # above ``jobs:``. ``unit-tests`` is listed since #1052, when the gate stopped # preceding ``version_check``; this ``if:`` calls no status-check function, - # so the implicit ``success()`` requires the gate to have passed. + # so the implicit ``success()`` requires the gate to have passed. The + # release App's private key is a ``github-release`` environment secret, so + # no job reads it before this approval, and no other job reads it at all. environment: github-release permissions: contents: write @@ -400,7 +437,7 @@ jobs: - name: Checkout code uses: actions/checkout@v7 with: - # ref: v${{ needs.version_check.outputs.PCAPKIT_VERSION }} + ref: ${{ github.sha }} fetch-depth: 0 # ``CHANGELOG.md`` is generated by ``util/changelog_md.py`` from the newest @@ -452,11 +489,30 @@ jobs: echo "::warning title=Changelog entry is undated::The entry for ${PCAPKIT_VERSION} still reads 'unreleased', so the release body says so too. Date the heading in docs/source/changelog/${PCAPKIT_VERSION}.rst, regenerate CHANGELOG.md and the body can be corrected in place." fi + # #1634: for a target that "adds or modifies any file under + # .github/workflows/ relative to the repository's default branch", + # https://docs.github.com/en/rest/releases/releases#create-a-release + # requires of "GitHub App installation tokens" the "Workflows" repository + # permission (write), whereas "The GITHUB_TOKEN available to GitHub + # Actions cannot be authorized for this". With no ``owner`` or + # ``repositories`` the token is "scoped to only the current repository", + # and the action's ``post`` step revokes it (v3.3.0's README, which ``v3`` + # names). Skipped while ``vars.RELEASE_APP_CLIENT_ID`` is unset, which + # reads as ``''``, so Create Release spends ``GITHUB_TOKEN`` until the App + # is set up. + - name: Mint the release App's token + id: app_token + if: ${{ vars.RELEASE_APP_CLIENT_ID != '' }} + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + permission-workflows: write + - name: Create Release id: create_release uses: softprops/action-gh-release@v3.0.2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token with: # allowUpdates: true # artifacts: | @@ -472,7 +528,68 @@ jobs: name: PyPCAPKit v${{ needs.version_check.outputs.PCAPKIT_VERSION }} prerelease: ${{ needs.version_check.outputs.PCAPKIT_PRERELEASE }} tag_name: "v${{ needs.version_check.outputs.PCAPKIT_VERSION }}" - token: "${{ secrets.GITHUB_TOKEN }}" + # #1634: on ``workflow_run`` this step creates the tag, and an unset + # target would put it on the default branch -- whatever ``main`` had + # become by approval. On a ``v*`` push the tag already exists, and the + # check below proves it names ``github.sha``, so this is ``''``, which + # the action reads as unset: the REST docs apply the workflow-file rule + # to a given target, else to the default branch's latest commit, so a + # target here would expose a hand-pushed tag to it under + # ``GITHUB_TOKEN``. ``pypi`` and ``conda`` upload with ``gh release + # upload``, which never calls Update a release, so nothing here + # re-checks this target once the release exists. cron-conda.yml's + # upload does, naming the default branch as its target: see + # releasing.rst. + target_commitish: ${{ github.event_name == 'workflow_run' && github.sha || '' }} + # The App's token, or ``GITHUB_TOKEN`` when the step above skipped and + # left its output empty. "A non-empty explicit token overrides + # GITHUB_TOKEN" (3.0.2's ``action.yml``), so no ``env:`` names one. + token: ${{ steps.app_token.outputs.token || secrets.GITHUB_TOKEN }} + + # #1634: the same check ``tag``, ``pypi`` and ``conda`` make before they + # publish under this tag. Here it catches the tag having existed already + # at another commit, which ``action-gh-release`` silently reuses. + - name: Check the release tags name this run's commit + env: + PCAPKIT_TAGS: v${{ needs.version_check.outputs.PCAPKIT_VERSION }} + PCAPKIT_RUN_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + run_commit="$(git rev-parse --verify "${PCAPKIT_RUN_SHA}^{commit}")" + for tag in ${PCAPKIT_TAGS}; do + git fetch --quiet --force origin "refs/tags/${tag}:refs/tags/${tag}" || true + tagged="$(git rev-parse -q --verify "refs/tags/${tag}^{commit}" || true)" + if [ "$tagged" != "$run_commit" ]; then + echo "::error title=${tag} is not this run's commit (#1634)::${tag} resolves to '${tagged:-no commit}', but this run was created for ${run_commit}. Refusing to publish this tree under it: every artefact of a release comes from its tag's commit, so finish that release by re-running the run created for that commit." + exit 1 + fi + echo "${tag} -> ${run_commit}" + done + + # #1634: a refusal here is most likely the workflow-file rule quoted above + # the token step, which GitHub's 2023-11-02 changelog words more narrowly + # -- a target that itself modifies a workflow file and has no branch head + # or tag; which reading is enforced is unmeasured. What to fix depends on + # the token Create Release spent: ``GITHUB_TOKEN`` cannot be authorized, + # so the release App needs setting up; the App's token can be, so its + # permissions or installation are wrong. For a final release the + # action creates a draft and publishes it with Update a release in this + # same step; a re-run would reuse a draft left behind, target and all, so + # it is deleted first. + - name: Explain a refused release tag + if: ${{ failure() && steps.create_release.outcome == 'failure' }} + env: + PCAPKIT_VERSION: ${{ needs.version_check.outputs.PCAPKIT_VERSION }} + PCAPKIT_RUN_SHA: ${{ github.sha }} + PCAPKIT_APP_TOKEN_OUTCOME: ${{ steps.app_token.outcome }} + run: | + if [ "$PCAPKIT_APP_TOKEN_OUTCOME" = success ]; then + fix="Create Release spent the release App's token, so check that the App still has Contents and Workflows read and write and is installed on this repository." + else + fix="Create Release spent GITHUB_TOKEN, which cannot be authorized for this, because vars.RELEASE_APP_CLIENT_ID is unset. Set up the release App once, as docs/source/contributing/releasing.rst describes: Contents and Workflows read and write, installed on this repository only; repository variables RELEASE_APP_CLIENT_ID and RELEASE_APP_BOT_ID; github-release environment secret RELEASE_APP_PRIVATE_KEY." + fi + echo "::error title=Could not create v${PCAPKIT_VERSION} at ${PCAPKIT_RUN_SHA} (#1634)::If Create Release failed with 404 Not Found or 403 'Resource not accessible by integration', the likely cause is GitHub's workflow-file rule: its REST docs for Create a release require a token authorized to modify workflows for a commit that adds or modifies a file under .github/workflows/ relative to the default branch, which this commit may if main has changed one since. ${fix} Then delete any draft release v${PCAPKIT_VERSION} this left behind, since a re-run would reuse it and its target, and re-run this job. Any other failure is not this." tag: # "(release)" distinguishes this from the identically named job in @@ -493,6 +610,10 @@ jobs: # ``!cancelled()`` so that ``github`` legitimately skipping (its own tag # already existing) does not cascade-skip this job before its ``if:`` is # even evaluated -- see the #888 note above ``jobs:``. + # + # #1634: "Create Tag" tags ``github.sha`` before anything here looks at + # ``main``, and "Reset Build Number" is a separate commit on top of + # ``main`` that never moves the tag. Its rules are in its own comment. environment: conda-tag permissions: {} needs: [ github, unit-tests, version_check ] @@ -507,49 +628,134 @@ jobs: with: ssh-private-key: ${{ secrets.PYPCAPKIT_ACTIONS_DEPLOY_KEY }} + # A detached checkout of this run's commit. This job used to check out a + # local ``main`` and run ``git pull --rebase origin main`` before tagging, + # which fast-forwarded it onto ``main``'s tip first -- ``b6b95bc8e`` to + # ``2c0008c3c`` in job 115087308551 (#1634). - name: Checkout code uses: actions/checkout@v7 with: token: ${{ secrets.PYPCAPKIT }} - # ref: v${{ needs.version_check.outputs.PCAPKIT_VERSION }} + ref: ${{ github.sha }} fetch-depth: 0 - - name: Reset Build Number + # Everything after this step writes through the deploy key loaded above. + # Kept to these lines alone: the project test runs the two steps below + # against a scratch repository, standing in for this one. + - name: Configure git for the deploy key run: | - set -x - - printf 0 > conda/build + git remote set-url origin git@github.com:JarryShaw/PyPCAPKit.git + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" - - name: Commit Changes + # ``conda-+0`` goes on this run's commit, next to the ``v*`` tag + # ``github`` put there -- and only if that tag is there: a ``v*`` tag on + # another commit means the release belongs to that commit, not this one. + # An existing ``conda-+0`` on this commit is a re-run and is left + # alone; one on another commit is never moved. + - name: Create Tag + env: + PCAPKIT_VERSION: ${{ needs.version_check.outputs.PCAPKIT_VERSION }} + PCAPKIT_RUN_SHA: ${{ github.sha }} run: | - set -x + set -euo pipefail - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" + run_commit="$(git rev-parse --verify "${PCAPKIT_RUN_SHA}^{commit}")" - git add conda/build - git commit -m"Reset conda build number" || true + release_tag="v${PCAPKIT_VERSION}" + released="$(git rev-parse -q --verify "refs/tags/${release_tag}^{commit}" || true)" + if [ "$released" != "$run_commit" ]; then + echo "::error title=${release_tag} is not this run's commit (#1634)::${release_tag} resolves to '${released:-no commit}', not ${run_commit}, the commit this run was created for. That release is not this commit's, so this commit gets no conda tag either." + exit 1 + fi - - name: Push Changes - run: | - git remote set-url origin git@github.com:JarryShaw/PyPCAPKit.git - git pull --rebase origin main - git push origin HEAD:main + conda_tag="conda-${PCAPKIT_VERSION}+0" + tagged="$(git rev-parse -q --verify "refs/tags/${conda_tag}^{commit}" || true)" + if [ -z "$tagged" ]; then + git tag "$conda_tag" -m"Conda Build" "$run_commit" + git push origin "refs/tags/${conda_tag}" + echo "${conda_tag} -> ${run_commit}" + elif [ "$tagged" = "$run_commit" ]; then + echo "::notice title=${conda_tag} already tagged::${conda_tag} already names ${run_commit}; nothing to tag." + else + echo "::error title=${conda_tag} is not this run's commit (#1634)::${conda_tag} already names ${tagged}, not ${run_commit}. A pushed tag is never moved here." + exit 1 + fi - - name: Create Tag + # The reset is a commit of its own on top of ``main``, never folded into + # the tag above and never a rewrite: ``main`` is only ever fast-forwarded, + # by a plain ``git push`` of one new commit whose parent is the tip just + # fetched. It sets the tip's ``conda/build`` to 0, so the next Conda + # Update bump of this version is ``+1`` -- unless: + # + # * this run's commit is not on ``main`` (a ``v*`` tag on a side branch) + # -- ``main`` is left alone, with a warning; + # * the tip already says build 0 -- the usual case, since + # ``util/bump_version.py`` writes 0 with every bump; + # * the tip carries another ``__version__`` -- its counter belongs to + # that version, left alone; + # * origin already has a ``conda-+N`` tag other than ``+0`` -- + # rewinding would let a later bump reuse that number, so ``main`` is + # left alone, with a warning. + # + # #1634's own shape -- this commit at build 0, then three Conda Update + # bumps that #1632 left untagged -- gets the reset: the tip's 3 becomes 0. + # A rejected push means ``main`` moved in between; the whole decision is + # made again from a fresh fetch, three times at most. + - name: Reset Build Number + env: + PCAPKIT_VERSION: ${{ needs.version_check.outputs.PCAPKIT_VERSION }} + PCAPKIT_RUN_SHA: ${{ github.sha }} run: | - set -x + set -euo pipefail - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" + run_commit="$(git rev-parse --verify "${PCAPKIT_RUN_SHA}^{commit}")" + run_version="$(git show "${run_commit}:pcapkit/__init__.py" | sed -n 's/^__version__ = //p')" + builds="refs/tags/conda-${PCAPKIT_VERSION}+" - git tag -d v${{ needs.version_check.outputs.PCAPKIT_VERSION }} || true - git tag "conda-${{ needs.version_check.outputs.PCAPKIT_VERSION }}+0" -m"Conda Build" + for attempt in 1 2 3; do + git fetch --quiet origin "+refs/heads/main:refs/remotes/origin/main" + tip="$(git rev-parse refs/remotes/origin/main)" - - name: Push Tag - run: | - git remote set-url origin git@github.com:JarryShaw/PyPCAPKit.git - git push origin "conda-${{ needs.version_check.outputs.PCAPKIT_VERSION }}+0" + if ! git merge-base --is-ancestor "$run_commit" "$tip"; then + echo "::warning title=conda/build not reset::${run_commit} is not on main (tip ${tip}), so main is left alone; its conda/build still reads what it did." + exit 0 + fi + tip_build="$(git show "${tip}:conda/build" | tr -d '[:space:]')" + if [ "$tip_build" = "0" ]; then + echo "::notice title=No conda build reset needed::main (${tip}) already carries conda/build 0." + exit 0 + fi + tip_version="$(git show "${tip}:pcapkit/__init__.py" | sed -n 's/^__version__ = //p')" + if [ "$tip_version" != "$run_version" ]; then + echo "::notice title=conda/build not reset::main (${tip}) has moved on to __version__ ${tip_version}; its conda/build ${tip_build} counts that version's builds." + exit 0 + fi + tagged="$(git ls-remote --tags --refs origin "${builds}*")" + taken="" + while read -r _ ref; do + build="${ref#"$builds"}" + if [ "$build" != "$ref" ] && [ "$build" != "0" ]; then + taken="${taken:+${taken} }+${build}" + fi + done <<< "$tagged" + if [ -n "$taken" ]; then + echo "::warning title=conda/build not reset::conda-${PCAPKIT_VERSION} is already tagged at ${taken}; resetting conda/build ${tip_build} on main (${tip}) to 0 would let a later Conda Update bump reuse one of those build numbers." + exit 0 + fi + + git switch --quiet --force --detach "$tip" + printf 0 > conda/build + git commit --quiet -m"Reset conda build number" -m"conda-${PCAPKIT_VERSION}+0 was tagged at ${run_commit}." -- conda/build + if git push origin "HEAD:refs/heads/main"; then + echo "conda/build reset from ${tip_build} to 0 on main in $(git rev-parse HEAD), on top of ${tip}" + exit 0 + fi + echo "push to main rejected on attempt ${attempt}; fetching again" + done + + echo "::error title=conda/build not reset::main kept moving; three pushes were rejected. ${run_commit} is tagged regardless -- reset conda/build on main by hand if no conda-${PCAPKIT_VERSION}+N tag other than +0 exists." + exit 1 pypi: name: PyPI distribution for Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }} @@ -611,12 +817,33 @@ jobs: - python-version: "pypy3.10" ktlo: true steps: + # #1634: this run's commit, not ``ref: v`` -- that tag was + # whatever ``main`` had become by approval time -- and then the check + # below that the tag these files are filed under names it too. - name: Checkout code uses: actions/checkout@v7 with: - ref: v${{ needs.version_check.outputs.PCAPKIT_VERSION }} + ref: ${{ github.sha }} fetch-depth: 0 + - name: Check the release tags name this run's commit + env: + PCAPKIT_TAGS: v${{ needs.version_check.outputs.PCAPKIT_VERSION }} + PCAPKIT_RUN_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + run_commit="$(git rev-parse --verify "${PCAPKIT_RUN_SHA}^{commit}")" + for tag in ${PCAPKIT_TAGS}; do + git fetch --quiet --force origin "refs/tags/${tag}:refs/tags/${tag}" || true + tagged="$(git rev-parse -q --verify "refs/tags/${tag}^{commit}" || true)" + if [ "$tagged" != "$run_commit" ]; then + echo "::error title=${tag} is not this run's commit (#1634)::${tag} resolves to '${tagged:-no commit}', but this run was created for ${run_commit}. Refusing to publish this tree under it: every artefact of a release comes from its tag's commit, so finish that release by re-running the run created for that commit." + exit 1 + fi + echo "${tag} -> ${run_commit}" + done + - uses: actions/setup-python@v7 id: setup continue-on-error: ${{ matrix.ktlo == true }} @@ -672,21 +899,65 @@ jobs: skip-existing: true attestations: false + # #1634: ``gh release upload``, not ``action-gh-release``, which calls + # Update a release before every upload (3.0.2's ``src/github.ts:631-683``). + # GitHub's REST docs re-check a release's existing target -- ``github.sha`` + # on ``workflow_run`` -- against the workflow-file rule there, so a + # workflow change reaching ``main`` after ``github`` ran would fail this + # step after PyPI had published. Upload a release asset's docs state no + # such rule; ``gh`` reads the release and POSTs each file to its + # ``upload_url``. #1640: a file already on the release is left alone, where + # the action deleted and re-uploaded it, and a rebuild is not + # byte-identical -- but only an asset in state ``uploaded``: after a 502, + # Upload a release asset "may leave an empty asset with a state of + # starter", which ``--clobber`` deletes before uploading the file again. + # Two failures the action did not have: no release fails ``gh release + # view`` with "release not found", where the action created one with the + # default branch as its target, and a pattern matching nothing is an + # ``::error``, where it only warned. ``conda`` runs the same script. - name: Upload package to GitHub Release id: upload continue-on-error: ${{ matrix.ktlo == true }} - uses: softprops/action-gh-release@v3.0.2 - with: - # allowUpdates: true - files: | - dist/* - tag_name: "v${{ needs.version_check.outputs.PCAPKIT_VERSION }}" - token: "${{ secrets.GITHUB_TOKEN }}" + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PCAPKIT_RELEASE_TAG: v${{ needs.version_check.outputs.PCAPKIT_VERSION }} + PCAPKIT_ASSETS: dist/* + run: | + set -euo pipefail + + published="$(gh release view "$PCAPKIT_RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[] | [.name, .state] | @tsv')" + while IFS= read -r pattern; do + [ -n "$pattern" ] || continue + for file in $pattern; do + if [ ! -f "$file" ]; then + echo "::error title=Nothing to upload::'${pattern}' matches no file, so ${PCAPKIT_RELEASE_TAG} would go without it." + exit 1 + fi + name="${file##*/}" + state="" + while IFS=$'\t' read -r asset asset_state; do + [ "$asset" != "$name" ] || state="$asset_state" + done <<< "$published" + if [ "$state" = uploaded ]; then + echo "::notice title=${name} already on ${PCAPKIT_RELEASE_TAG}::Left as published (#1640): this run's rebuild is not byte-identical to it." + continue + fi + if [ -n "$state" ]; then + echo "::warning title=${name} uploaded again::${PCAPKIT_RELEASE_TAG} holds it in state '${state}', not 'uploaded' -- an upload that failed partway -- so it is deleted and this file uploaded in its place." + gh release upload "$PCAPKIT_RELEASE_TAG" "$file" --repo "$GITHUB_REPOSITORY" --clobber + else + gh release upload "$PCAPKIT_RELEASE_TAG" "$file" --repo "$GITHUB_REPOSITORY" + fi + echo "${name} -> ${PCAPKIT_RELEASE_TAG}" + done + done <<< "$PCAPKIT_ASSETS" # #1633: what this KTLO leg's steps did before ``continue-on-error``, # for ``release_status`` -- ``steps.*.outcome`` covers the steps with an # ``id`` above, i.e. every one that can fail here except the TestPyPI - # upload, which no leg treats as a failure. + # upload, which no leg treats as a failure, and #1634's tag check, which + # fails every leg alike and so blocks every leg. - name: Record this KTLO leg's outcome id: ktlo if: ${{ !cancelled() && matrix.ktlo == true }} @@ -748,11 +1019,32 @@ jobs: - "3.14" - "3.15" steps: + # #1634: this run's commit, not ``ref: conda-+0``, then the check + # that both tags these packages are filed under name it. - uses: actions/checkout@v7 with: - ref: conda-${{ needs.version_check.outputs.PCAPKIT_VERSION }}+0 + ref: ${{ github.sha }} fetch-depth: 0 + - name: Check the release tags name this run's commit + shell: bash + env: + PCAPKIT_TAGS: v${{ needs.version_check.outputs.PCAPKIT_VERSION }} conda-${{ needs.version_check.outputs.PCAPKIT_VERSION }}+0 + PCAPKIT_RUN_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + run_commit="$(git rev-parse --verify "${PCAPKIT_RUN_SHA}^{commit}")" + for tag in ${PCAPKIT_TAGS}; do + git fetch --quiet --force origin "refs/tags/${tag}:refs/tags/${tag}" || true + tagged="$(git rev-parse -q --verify "refs/tags/${tag}^{commit}" || true)" + if [ "$tagged" != "$run_commit" ]; then + echo "::error title=${tag} is not this run's commit (#1634)::${tag} resolves to '${tagged:-no commit}', but this run was created for ${run_commit}. Refusing to publish this tree under it: every artefact of a release comes from its tag's commit, so finish that release by re-running the run created for that commit." + exit 1 + fi + echo "${tag} -> ${run_commit}" + done + # #888: this is the per-leg correctness fix -- see the job comment above. # Anaconda's read API reported ``cf-cache-status: DYNAMIC`` on every # request checked while writing this, i.e. not cached, so unlike PyPI's @@ -887,6 +1179,9 @@ jobs: shell: bash -el {0} env: PCAPKIT_VERSION: ${{ needs.version_check.outputs.PCAPKIT_VERSION }} + # Build 0 because this is the build ``conda-+0`` names, not + # because ``conda/build`` says so: at a hand-tagged commit it may still + # hold the previous version's counter until ``tag``'s reset (#1634). PCAPKIT_BUILD: "0" working-directory: conda/pypcapkit run: | @@ -946,14 +1241,47 @@ jobs: packages: ${{ steps.conda_build.outputs.packages }} labels: ${{ needs.version_check.outputs.PCAPKIT_CONDA_LABEL }} + # ``pypi``'s upload script, for the same two reasons (#1634, #1640). Not + # gated on ``check_leg`` as the Anaconda upload is: a leg whose Anaconda + # upload landed and whose GitHub upload then failed would get no GitHub + # asset on any re-run. Skipping a file the release already has is what + # stops the replacement #1640 measured. - name: Upload package to GitHub Release if: ${{ matrix.os == 'ubuntu-latest' }} - uses: softprops/action-gh-release@v3.0.2 - with: - # allowUpdates: true - files: ${{ steps.conda_build.outputs.packages }} - tag_name: "v${{ needs.version_check.outputs.PCAPKIT_VERSION }}" - token: "${{ secrets.GITHUB_TOKEN }}" + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PCAPKIT_RELEASE_TAG: v${{ needs.version_check.outputs.PCAPKIT_VERSION }} + PCAPKIT_ASSETS: ${{ steps.conda_build.outputs.packages }} + run: | + set -euo pipefail + + published="$(gh release view "$PCAPKIT_RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[] | [.name, .state] | @tsv')" + while IFS= read -r pattern; do + [ -n "$pattern" ] || continue + for file in $pattern; do + if [ ! -f "$file" ]; then + echo "::error title=Nothing to upload::'${pattern}' matches no file, so ${PCAPKIT_RELEASE_TAG} would go without it." + exit 1 + fi + name="${file##*/}" + state="" + while IFS=$'\t' read -r asset asset_state; do + [ "$asset" != "$name" ] || state="$asset_state" + done <<< "$published" + if [ "$state" = uploaded ]; then + echo "::notice title=${name} already on ${PCAPKIT_RELEASE_TAG}::Left as published (#1640): this run's rebuild is not byte-identical to it." + continue + fi + if [ -n "$state" ]; then + echo "::warning title=${name} uploaded again::${PCAPKIT_RELEASE_TAG} holds it in state '${state}', not 'uploaded' -- an upload that failed partway -- so it is deleted and this file uploaded in its place." + gh release upload "$PCAPKIT_RELEASE_TAG" "$file" --repo "$GITHUB_REPOSITORY" --clobber + else + gh release upload "$PCAPKIT_RELEASE_TAG" "$file" --repo "$GITHUB_REPOSITORY" + fi + echo "${name} -> ${PCAPKIT_RELEASE_TAG}" + done + done <<< "$PCAPKIT_ASSETS" # #888: the point of this job is that a run which released nothing must say # why, in one place, regardless of which upstream job or gate is what made it @@ -986,6 +1314,8 @@ jobs: env: PCAPKIT_EVENT_NAME: ${{ github.event_name }} PCAPKIT_WORKFLOW_RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + PCAPKIT_ACTOR_ID: ${{ github.actor_id }} + PCAPKIT_RELEASE_APP_BOT_ID: ${{ vars.RELEASE_APP_BOT_ID }} PCAPKIT_UNIT_TESTS: ${{ needs.unit-tests.result }} PCAPKIT_VERSION_CHECK: ${{ needs.version_check.result }} PCAPKIT_GITHUB_JOB: ${{ needs.github.result }} @@ -1019,9 +1349,14 @@ jobs: # update (`skipped`), which is routine and gets a quiet notice; any # other conclusion gets a warning, because it means Vendor Update # itself needs attention, even though it is not #888 -- no tag exists - # here that could be left stranded. + # here that could be left stranded. On a push it is quiet only for + # the release App's own tag (#1634), which version_check skips by + # design; an unset bot id never matches here. if [ "$PCAPKIT_VERSION_CHECK" = "skipped" ]; then - if [ "$PCAPKIT_EVENT_NAME" = "workflow_run" ] && [ "$PCAPKIT_WORKFLOW_RUN_CONCLUSION" = "skipped" ]; then + if [ "$PCAPKIT_EVENT_NAME" = "push" ] && [ -n "$PCAPKIT_RELEASE_APP_BOT_ID" ] \ + && [ "$PCAPKIT_ACTOR_ID" = "$PCAPKIT_RELEASE_APP_BOT_ID" ]; then + echo "::notice title=Nothing to release::the release App's bot pushed this tag from the Create Release run that released its commit, so this run correctly released nothing (#1634)." + elif [ "$PCAPKIT_EVENT_NAME" = "workflow_run" ] && [ "$PCAPKIT_WORKFLOW_RUN_CONCLUSION" = "skipped" ]; then echo "::notice title=Nothing to release::the triggering Vendor Update run found nothing to update, so this run correctly released nothing." elif [ "$PCAPKIT_EVENT_NAME" = "workflow_run" ]; then echo "::warning title=Release skipped::the triggering Vendor Update run concluded '${PCAPKIT_WORKFLOW_RUN_CONCLUSION}', not 'success', so version_check, the release test gate and every release job were skipped. Not #888 -- no tag was created that could be left stranded -- but Vendor Update likely needs attention." diff --git a/.github/workflows/cron-conda.yml b/.github/workflows/cron-conda.yml index 7023603f7..a6bd4439e 100644 --- a/.github/workflows/cron-conda.yml +++ b/.github/workflows/cron-conda.yml @@ -535,11 +535,52 @@ jobs: labels: ${{ needs.conda-update.outputs.PCAPKIT_CONDA_LABEL }} force: true - - name: Upload package to GitHub Release + # #1634: the upload below never makes the release. ncipollo/release-action + # v1.21.0 creates a missing one, and its tag, at ``commit`` + # (src/Action.ts:72-92), and publishes a draft, since an unset ``draft`` + # is false on update (src/Inputs.ts:210-213). A bump made while Create + # Release waits on approval would then tag the pending version at main's + # tip, and Create Release's tag check would fail. Only the GitHub upload + # is skipped; the build is on anaconda.org regardless. + - name: Check the GitHub release exists + id: release if: ${{ matrix.os == 'ubuntu-latest' }} + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PCAPKIT_RELEASE_TAG: v${{ needs.conda-update.outputs.PCAPKIT_VERSION }} + run: | + errors="$(mktemp)" + status=0 + draft="$(gh release view "$PCAPKIT_RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft 2> "$errors")" || status=$? + if [ "$status" -eq 0 ] && [ "$draft" = false ]; then + echo "published=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$status" -eq 0 ] && [ "$draft" = true ]; then + why="is a draft, which the upload would publish" + elif [ "$(cat "$errors")" = 'release not found' ]; then + why="does not exist, and the upload would create it" + else + cat "$errors" >&2 + echo "::error title=Could not read ${PCAPKIT_RELEASE_TAG}::gh release view exited ${status}." + exit 1 + fi + echo "::notice title=No GitHub upload for ${PCAPKIT_RELEASE_TAG}::${PCAPKIT_RELEASE_TAG} ${why}, tagging main's tip. Create Release makes it, at the commit its run was created for (#1634); this build is on anaconda.org only." + echo "published=false" >> "$GITHUB_OUTPUT" + + # ``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'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). + - name: Upload package to GitHub Release + if: ${{ matrix.os == 'ubuntu-latest' && steps.release.outputs.published == 'true' }} uses: ncipollo/release-action@v1.21.0 with: allowUpdates: true artifacts: ${{ steps.conda_build.outputs.packages_csv }} + commit: ${{ github.ref_name }} tag: "v${{ needs.conda-update.outputs.PCAPKIT_VERSION }}" token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index aa0f846a0..eb850f62f 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -44,12 +44,12 @@ Editing ``__version__`` by hand without also moving :file:`CITATION.cff`'s ``tests/project/test_bump_version.py``'s ``RepositoryCitationTests.test_the_citation_file_names_the_packaged_version`` (``:494-514``) asserts the two agree, and ``create-release.yml``'s -``unit-tests`` job (``:367-381``) calls ``unit-tests.yml`` with +``unit-tests`` job (``:401-415``) calls ``unit-tests.yml`` with ``gate-only: true``, which runs the **full** suite rather than the tiered subset an ordinary push runs (its ``gate`` job, ``unit-tests.yml:1481-1550``). That gate runs whenever a release will -- a moved ``__version__`` is exactly what makes ``version_check``'s evidence read "not yet published" -- and every -publishing job requires it to have succeeded (``:397,498,578,726``), so a +publishing job requires it to have succeeded (``:434,619,784,997``), so a citation left behind fails the gate well before any approval is requested. Either run the script, or move both fields by hand in the same commit. @@ -60,7 +60,7 @@ process: the ``changelog`` job (``unit-tests.yml:1341-1362``) fails outright if ``CHANGELOG.md`` has drifted from its source entry under :file:`docs/source/changelog/`, and the release-body step warns, without failing, if that entry's heading still reads "unreleased" -(``create-release.yml:451-452``). Both enforce what the changelog convention +(``create-release.yml:488-489``). Both enforce what the changelog convention already asks for. Two Ways to Start a Release @@ -73,11 +73,12 @@ neither is a workaround for the other: ``push: tags: ['v*']`` Pushing a tag matching ``v*`` yourself. This chooses **which commit** to release, not which version: ``version_check``'s checkout - (``:183-186``) carries no explicit ``ref:``, so it checks out whatever - commit the pushed tag points at, and reads ``pcapkit.__version__`` out of - *that* tree (``:203``). Every downstream tag and release name is built - from that output -- ``tag_name: "v${{ needs.version_check.outputs.PCAPKIT_VERSION }}"`` - appears at ``:469``, ``:650`` and ``:892``, always from + (``:216-220``) pins ``ref: ${{ github.sha }}``, which on this trigger is + the commit the pushed tag points at, and reads ``pcapkit.__version__`` out + of *that* tree (``:237``). Every downstream tag and release name is built + from that output -- ``v${{ needs.version_check.outputs.PCAPKIT_VERSION }}`` + is ``github``'s ``tag_name`` at ``:530`` and the release ``pypi`` and + ``conda`` upload to at ``:924`` and ``:1254``, always from ``PCAPKIT_VERSION``, never from ``github.ref_name`` -- so the version actually released is whatever ``__version__`` says on the tagged commit, not the name of the tag that was pushed. The two agree only if the tag @@ -93,10 +94,13 @@ neither is a workaround for the other: The path a version bump on ``main`` takes without anyone pushing a tag: a push to ``main`` completes **Unit Tests**, whose completion triggers **Vendor Update** (``cron-vendor.yml``), whose completion -- bump step run - or not -- triggers this workflow. A ``workflow_run``-triggered run always - evaluates the tip of the default branch (documented GitHub Actions - behaviour, not measured here), so ``github.ref_name`` reads ``main``, - never a tag. + or not -- triggers this workflow. A ``workflow_run``-triggered run + evaluates the tip of the default branch as it was when the run was + *created*: ``github.ref_name`` reads ``main``, never a tag, and + ``github.sha`` is that tip, fixed for the life of the run. On run + 38008819343 it was ``b6b95bc8e``, the bump Vendor Update had just pushed, + while ``github.event.workflow_run.head_sha`` -- the commit Vendor Update + ran *on* -- was the bump's parent, ``945c3c3d8``. Both paths converge on the same job graph from ``version_check`` onward; only the trigger and ``github.ref_name`` differ, which is what the guards below @@ -106,6 +110,159 @@ when some target's evidence is not yet ``'true'`` common Vendor Update run that bumped nothing costs a minute, not a full-suite run. +One Commit per Release +---------------------- + +Every job that checks out, tags, builds or publishes pins ``github.sha`` +(`#1634 `__), so a run +releases the commit it was created for however long it waits on approval. +Before that, run 38008819343 was created for ``b6b95bc8e``, approved three +days later, and tagged ``main``'s tip ``2c0008c3c`` as both ``v1.5.0b9`` and +``conda-1.5.0b9+0``: ``action-gh-release`` defaulted ``target_commitish`` to +the default branch, and ``tag`` pulled ``main`` before tagging. + +* On ``workflow_run``, where ``github`` creates the ``v*`` tag, it passes + ``target_commitish`` ``github.sha`` (``:543``); on a ``v*`` push the tag + already exists and it passes none. Either way it then checks that the tag + names that commit. +* ``tag`` cuts ``conda-+0`` at that commit (``:656-683``) before it + reads ``main`` at all; the :file:`conda/build` reset is a separate commit on + top of ``main`` (``:705-758``), described under `Precautions`_. +* ``pypi`` and ``conda`` check out that commit and, before building, check + that every tag they publish under names it, as ``tag`` does for the ``v*`` + tag. A run created for a *later* commit of the same version therefore fails + with an ``::error`` instead of publishing a second tree under that version; + a partial release is finished by re-running the run that tagged it. +* ``pypi`` and ``conda`` attach their files with ``gh release upload`` + (``:918-954``, ``:1249-1284``), not ``action-gh-release``, which calls Update + a release before every upload (3.0.2's ``src/github.ts:631-683``). A file + the release already has is left alone + (`#1640 `__): the action + deleted and re-uploaded it, and a rebuild against live PyPI is not + byte-identical to the published copy. Only an asset in state ``uploaded`` + counts: `Upload a release asset + `__ + says a ``502`` "may leave an empty asset with a state of starter. It can be + safely deleted.", so an asset in any other state is deleted and uploaded + again (``--clobber``). ``conda``'s upload is not gated on ``check_leg``, so a + leg whose Anaconda upload landed but whose GitHub upload failed still gets + its asset on a re-run. +* Two failures are new, and deliberate. With no release for the tag, the + upload fails with ``gh``'s "release not found", where the action made one, + with no notes and ``target_commitish`` unset (``src/github.ts:617-628,1026``), + which Create a release defaults to "the repository's default branch"; the + release is ``github``'s to make, at the run's commit. A file pattern matching + nothing exits 1 with ``::error title=Nothing to upload``, where the action, + ``fail_on_unmatched_files`` unset, only warned (``src/run.ts:15-20``) and + published the release without that file. + +Pinning the target may have a cost, and GitHub documents the rule two ways. +Its REST docs for `Create a release +`__ say +that if "the commit identified by target_commitish (or, when target_commitish +is omitted, the latest commit on the default branch) adds or modifies any file +under .github/workflows/ relative to the repository's default branch", the +token must be authorized to modify workflows, and "The GITHUB_TOKEN available +to GitHub Actions cannot be authorized for this"; the call fails with ``404 +Not Found``, or on some paths ``403 Resource not accessible by integration``. +`Update a release +`__ +applies it to "the new value of target_commitish if you are changing it, +otherwise the existing target"; `Upload a release asset +`__ +states no such rule. GitHub's `2023-11-02 changelog +`__ +instead requires that authorization for a ``target_commitish`` "that modifies +an Actions workflow file and that SHA does not have an existing ref (branch +head or tag)". + +The two readings disagree on run 38008819343. Against the default branch, +``b6b95bc8e..2c0008c3c`` changed ``ci-timing.yml`` and ``unit-tests.yml``, so +its ``github`` would have failed at Create Release. As the commit's own +change, ``b6b95bc8e`` touches no workflow file -- its diff against its parent +is :file:`CITATION.cff`, :file:`conda/build`, :file:`pcapkit/__init__.py` and +two ``apptype`` modules -- so it would not. Which reading GitHub enforces is +not measured here. `softprops/action-gh-release#411 +`__ was closed with +"The latest repro still hits ``403 Resource not accessible by integration`` +when ``github.token`` is used to create a release/tag against an older +commit. …", but the thread does not say whether that commit touched a +workflow file, so it fits both readings. + +Under the first, once ``main`` changes a workflow file after a run's commit, +Create Release refuses that run's ``GITHUB_TOKEN``, which is why ``github`` +spends a GitHub App's token instead (`The Release App`_). After ``github``, +nothing in this workflow calls Update a release, so by the docs nothing +re-checks the target (unmeasured). +``cron-conda.yml``'s ``conda-dist`` does: it attaches packages with +``ncipollo/release-action@v1.21.0`` and ``allowUpdates: true``, which updates +the release before uploading (``src/Action.ts:53-66,107-120``). It therefore +names the default branch -- ``github.ref_name`` on both its triggers -- as +``commit``, which that update sends as ``target_commitish`` +(``src/Releases.ts:150-174``), so the rule checks ``main`` itself; the tag +stays put, as Update a release calls the target "Unused if the Git tag already +exists". It also uploads only to a published release, read first with ``gh +release view``. With none, the action would create one, and its tag, at +``commit`` (``src/Action.ts:72-92``), and it would publish a draft, since an +unset ``draft`` is ``false`` on update (``src/Inputs.ts:210-213``). Conda +Update bumped 1.5.0b9's build three times while that run waited on approval; +once ``conda-dist`` runs on every bump +(`#1632 `__), either path +would tag the pending version at ``main``'s tip and fail Create Release's tag +check. That build then goes to anaconda.org only, with a ``::notice``. + +The Release App +--------------- + +The owner's choice on `#1634 +`__: ``github`` creates +the release with a GitHub App installation token, which Create a release +accepts for a target that changes a workflow file when it has the "Workflows" +repository permission (write). "Mint the release App's token" +(``create-release.yml:503-511``) runs +``actions/create-github-app-token@v3`` with ``permission-contents: write`` and +``permission-workflows: write`` and no ``owner`` or ``repositories``, so the +token is "scoped to only the current repository" and revoked in the action's +``post`` step (v3.3.0's README). Only Create Release spends it (``:547``). +While ``vars.RELEASE_APP_CLIENT_ID`` is unset, which reads as "an empty +string", the step skips and Create Release spends ``GITHUB_TOKEN``, so the +workflow keeps working, refusals included, until the App exists. A PAT would +work too, but its tag push is attributed to a person, so no guard could tell +it from a tag pushed by hand. + +One-time setup: + +1. Register a GitHub App with the **Contents** and **Workflows** repository + permissions at read and write, and install it on this repository only. +2. Add repository variables ``RELEASE_APP_CLIENT_ID``, the App's Client ID, and + ``RELEASE_APP_BOT_ID``, its bot's user id (``gh api + "/users/[bot]" --jq .id``, as the action's README reads it). +3. Store the App's private key as ``RELEASE_APP_PRIVATE_KEY``, a secret of the + ``github-release`` environment rather than of the repository: "If the + environment requires approval, a job cannot access environment secrets + until one of the required reviewers approves it", and ``github`` is the only + job that names the environment or reads the secret. + +Unlike ``GITHUB_TOKEN``'s, the App's tag starts a tag-push run. It would rerun +the release test gate, ask for a second approval and race the first run's +uploads from another concurrency group, so ``version_check`` skips a ``push`` +whose ``github.actor_id`` is ``vars.RELEASE_APP_BOT_ID`` (``:210-212``), and +``release_status`` reports it with ``::notice title=Nothing to release`` +(``:1356-1358``). A tag pushed by a person runs as before. Whether a tag made +through the Releases API fires ``push``, and with the bot as actor, is +unmeasured; if not, the guard never matches and the cost is one approval to +reject. + +A refused Create Release (404, or 403 "Resource not accessible by +integration") is explained by "Explain a refused release tag" (``:580-592``) +according to the token spent: with ``GITHUB_TOKEN`` it lists the setup above; +with the App's, it points at the App's permissions and installation. For a +final release, first delete any draft ``v`` the refused run left: +``action-gh-release`` creates a non-prerelease as a draft and publishes it +with Update a release in the same step (``src/github.ts:1025``), and the next +run would reuse that draft and its target (``:631-647`` there). Then re-run +the job; that a re-run reads variables and secrets set since is unmeasured. + Per-Job Evidence, Not a Shared Proxy ------------------------------------ @@ -179,8 +336,8 @@ their *publishing* predecessors -- ``github``, and for ``conda`` also ``tag``: predecessor's own result is ``cancelled``, which ``!= 'failure'`` still admits and the equality pair does not. Concretely: if ``tag`` is cancelled while the run itself is not, ``!= 'failure'`` would let ``conda`` proceed into its -``actions/checkout`` with ``ref: conda-+0``, a tag ``tag`` never -pushed, trading a clean skip for a checkout failure; the equality pair skips +check that ``conda-+0`` names this run's commit, a tag ``tag`` never +pushed, trading a clean skip for a failed step; the equality pair skips ``conda`` outright instead. ``version_check`` is the exception, and deliberately so: all three require @@ -208,7 +365,7 @@ The Release Pipeline VC --> UT UT["unit-tests -- Release test gate
skipped when nothing is publishable"] --> GH GH["github -- GitHub Release
creates/attaches the v* tag
THE ONLY APPROVAL"] - GH --> TG["tag -- Conda Tag
commit to main + conda-<version>+0 tag"] + GH --> TG["tag -- Conda Tag
conda-<version>+0 tag at github.sha
then conda/build reset on main"] GH --> PY["pypi -- build + publish
OIDC trusted publishing"] TG --> CD["conda -- build + upload
matrix: 2 OS, per-leg evidence"] GH --> CD @@ -234,8 +391,8 @@ Only ``github-release`` carries a required reviewer; ``conda-tag``, removed on `#887 `__. What still makes one approval mean *the whole release* is the ``needs:`` graph: ``tag`` and ``pypi`` both depend on ``github`` -(``.github/workflows/create-release.yml:498,578``), and ``conda`` depends on -``tag`` and ``github`` (``:726``) -- so nothing downstream of ``github`` can +(``.github/workflows/create-release.yml:619,784``), and ``conda`` depends on +``tag`` and ``github`` (``:997``) -- so nothing downstream of ``github`` can start before it is approved, and rejecting it leaves nothing tagged and nothing published. Before this change ``tag`` depended on ``version_check`` alone, so removing its reviewer without moving this dependency would have let @@ -258,8 +415,9 @@ Precautions above is what prevents it: ``tag``, ``pypi`` and ``conda`` each check whether *their own* artefact is missing rather than whether the ``v*`` tag exists, so an incomplete release runs the jobs that did not finish instead of skipping them. - A half-finished release self-heals on the next ``workflow_run``-triggered - attempt, or on re-running the workflow by hand -- see `Recovery`_ below. + A half-finished release self-heals on re-running the run that tagged it + -- see `Recovery`_ below; a run created for a later commit refuses instead + (`One Commit per Release`_). ``release_status`` is the other half: it runs unconditionally and reports, with a ``::notice``, a ``::warning`` or a failing ``::error``, why a run released nothing or that it released something, so a stranded release is @@ -281,7 +439,7 @@ back, and neither is needed, since a plain re-run self-heals; see **``environment: pypi`` stays even though its reviewer is gone.** ``pypi`` publishes through PyPI's OIDC trusted publishing (``environment: pypi`` at -``:568``, ``id-token: write`` at ``:572``), and PyPI's trusted-publisher +``:779``, ``id-token: write`` at ``:783``), and PyPI's trusted-publisher configuration can be scoped to a GitHub Actions environment name. If this project's publisher on PyPI is scoped that way, removing the ``pypi`` name -- not just its reviewer -- would break the upload with a claim mismatch; @@ -290,10 +448,19 @@ publisher's actual PyPI-side configuration was read to confirm here. Keep the name regardless, since there is no upside to removing it. **``conda-tag`` writes to ``main``, so a release is not read-only on the -branch.** The ``tag`` job resets :file:`conda/build` to ``0``, commits that, -and pushes straight to ``main`` -(``.github/workflows/create-release.yml:517-537``) before cutting the -``conda-+0`` tag. Approving ``github-release`` therefore also +branch.** After cutting ``conda-+0`` at the run's commit, the +``tag`` job pushes a commit setting :file:`conda/build` to ``0`` on top of +``main`` (``.github/workflows/create-release.yml:705-758``) -- a plain +fast-forward push, never a rewrite -- so the next Conda Update bump of that +version is ``+1``. There is none when the run's commit is not on ``main``, nor +when ``main``'s tip already says ``0`` (the usual case, since +:file:`util/bump_version.py` writes ``0``) or carries another ``__version__``, +nor when origin already has a ``conda-+N`` tag other than ``+0``, +since rewinding the counter would then reuse a tagged build number; the first +and last warn. #1634's own shape -- three Conda Update bumps that `#1632 +`__ left untagged -- gets +the reset: ``main``'s 3 becomes 0. A rejected push is retried from a fresh +fetch, three times at most. Approving ``github-release`` therefore also approves a commit landing on the default branch, not only the artefacts that sound like they are the point. @@ -301,15 +468,19 @@ Recovery -------- The sanctioned recovery from a failed or partial release run is re-running -the workflow, not re-tagging by hand, for the reasons above. This is the -actual fix rather than a best-effort suggestion: `Per-Job Evidence, Not a -Shared Proxy`_ above means ``tag``, ``pypi`` and ``conda`` each check whether +that run (**Re-run failed jobs**), not deleting and re-pushing a tag by hand, +for the reasons above; a run created for a later commit will not finish it +(`One Commit per Release`_). A run whose ``github`` was refused created no +tag, so re-running it once the App is set up (`The Release App`_) starts its +release rather than re-tagging it. This is the actual fix rather than a +best-effort suggestion: `Per-Job Evidence, Not a Shared Proxy`_ above means ``tag``, ``pypi`` and ``conda`` each check whether *their own* artefact is missing, so a re-run finishes whichever jobs did not complete last time instead of skipping them on the ``v*`` tag's mere existence. ``pypi`` is safe to re-run (``skip-existing: true``), and so is ``conda``, because each matrix leg checks Anaconda for its own platform/Python distribution before uploading and skips only that leg's -upload if it is already there. +upload if it is already there. Neither replaces a file the GitHub Release +already has (#1640). **A re-run does not need to be verified by hand against the PyPI and Anaconda listings, because ``release_status`` does it.** That job runs on every diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 3e751c010..94ec46aae 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -249,7 +249,7 @@ the caller's own workflow run, as one of the caller's own jobs, rather than starting an independent run after the caller finishes. All four calls in this repository target the same reusable workflow, with the same input: -* ``.github/workflows/create-release.yml:379`` -- job ``unit-tests`` calls +* ``.github/workflows/create-release.yml:413`` -- job ``unit-tests`` calls ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. * ``.github/workflows/cron-conda.yml:50`` -- job ``unit-tests`` calls ``./.github/workflows/unit-tests.yml`` with ``gate-only: true``. @@ -293,21 +293,25 @@ own graph. Create Release's first job, ``version_check`` -- ahead of the .. code-block:: yaml - if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + if: >- + ${{ (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') && + !(github.event_name == 'push' && github.actor_id == vars.RELEASE_APP_BOT_ID) }} -(``create-release.yml:179``). On the ``workflow_run`` path, this evaluates -**false**, and the job is skipped outright, whenever the upstream Vendor -Update run's own conclusion was anything other than ``success`` -- including +(``create-release.yml:210-212``). Its second half skips the tag-push run that +the release App's own tag starts (:doc:`releasing`, "The Release App"). On the +``workflow_run`` path, this evaluates **false**, and the job is skipped +outright, whenever the upstream Vendor Update run's own conclusion was +anything other than ``success`` -- including ``skipped``, which is exactly what Vendor Update's *own* jobs report when *their* ``workflow_run``-path guards decline (see ``cron-vendor.yml``'s ``vendor-update`` job). ``unit-tests`` then depends on that job with -``needs: [ version_check ]`` (``create-release.yml:369``) and an ``if:`` that +``needs: [ version_check ]`` (``create-release.yml:403``) and an ``if:`` that calls no status-check function, so a skipped dependency skips it too -- and every job past it is gated the same way: ``github`` needs ``[ unit-tests, -version_check ]`` (``:397``), ``tag`` needs ``[ github, unit-tests, -version_check ]`` (``:498``), ``pypi`` needs ``[ github, unit-tests, -version_check ]`` (``:578``), and ``conda`` needs ``[ tag, github, -unit-tests, version_check ]`` (``:726``). +version_check ]`` (``:434``), ``tag`` needs ``[ github, unit-tests, +version_check ]`` (``:619``), ``pypi`` needs ``[ github, unit-tests, +version_check ]`` (``:784``), and ``conda`` needs ``[ tag, github, +unit-tests, version_check ]`` (``:997``). .. mermaid:: @@ -333,7 +337,7 @@ unit-tests, version_check ]`` (``:726``). This is a transitive reduction, as in :doc:`releasing`: ``version_check`` and ``unit-tests`` are also direct ``needs:`` of ``tag`` and ``pypi`` -(``create-release.yml:498,578``) and of ``conda`` (``:726``), but ``github`` +(``create-release.yml:619,784``) and of ``conda`` (``:997``), but ``github`` already implies those edges, since it depends on both itself. Drawing them would add lines without changing which job can start before which, or which job a skip in ``version_check`` reaches. @@ -612,10 +616,10 @@ Deployment ``environment:`` Four jobs, all in ``create-release.yml``, declare a deployment ``environment:`` and can therefore pause for an approval: -* ``github`` -- ``environment: github-release`` (``create-release.yml:394``) -* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:496``) -* ``pypi`` -- ``environment: pypi`` (``create-release.yml:573``) -* ``conda`` -- ``environment: anaconda`` (``create-release.yml:723``) +* ``github`` -- ``environment: github-release`` (``create-release.yml:431``) +* ``tag`` -- ``environment: conda-tag`` (``create-release.yml:617``) +* ``pypi`` -- ``environment: pypi`` (``create-release.yml:779``) +* ``conda`` -- ``environment: anaconda`` (``create-release.yml:994``) No other workflow in scope here declares one -- including ``deploy-pages.yml``, whose ``deploy-pages`` job pushes straight to the diff --git a/tests/project/test_python_matrix_tiers_1633.py b/tests/project/test_python_matrix_tiers_1633.py index 3e8e2b4b1..712c42dc8 100644 --- a/tests/project/test_python_matrix_tiers_1633.py +++ b/tests/project/test_python_matrix_tiers_1633.py @@ -224,6 +224,8 @@ def test_no_ktlo_leg_can_fail_an_aggregated_job(self) -> None: condition = str(step.get('if', '')) if 'matrix.coverage' in condition or 'APT_CACHE_SAVE' in condition: continue # only ever on a supported leg: the coverage one, the cache saver + if label == "Check the release tags name this run's commit": + continue # #1634: blocks every leg alike, pinned in its own test module with self.subTest(workflow=name, job=job_id, step=label): self.assertIn(step.get('continue-on-error'), (True, KTLO_GUARD)) diff --git a/tests/project/test_release_gates.py b/tests/project/test_release_gates.py index e8b278ec2..34664b9e8 100644 --- a/tests/project/test_release_gates.py +++ b/tests/project/test_release_gates.py @@ -27,13 +27,13 @@ Be clear about how far that generalises, though, because it is easy to overstate. :data:`PUBLISHING_MARKERS` is what decides "publishing", and it is a finite, -hand-maintained list of four substrings. So the rule generalises across job +hand-maintained list of five substrings. So the rule generalises across job *identity* and not across publishing *mechanism*: a job uploading by some route -none of those four names -- ``twine upload``, ``gh release upload``, a fork of a -pinned action under another name, a ``curl`` straight at an index -- matches no -marker, and would pass this test while genuinely ungated. -:class:`TestMarkersStillMatch` only keeps the existing four from going blind; it -cannot invent a fifth. Adding a publishing route to this workflow therefore means +none of those five names -- ``twine upload``, a fork of a pinned action under +another name, a ``curl`` straight at an index -- matches no marker, and would +pass this test while genuinely ungated. +:class:`TestMarkersStillMatch` only keeps the existing five from going blind; it +cannot invent a sixth. Adding a publishing route to this workflow therefore means adding its marker here, and that is a maintenance obligation rather than something the test enforces. @@ -266,6 +266,8 @@ 'pypa/gh-action-pypi-publish': 'uploads a distribution to PyPI or TestPyPI', 'anaconda/actions/upload-package': 'uploads a package to anaconda.org', 'softprops/action-gh-release': 'creates a public GitHub Release, and the tag it names', + # ``pypi`` and ``conda`` attach their files this way since #1634. + 'gh release upload': 'attaches a file to a public GitHub Release', 'git push': 'pushes a commit or a tag to the repository', } @@ -1002,6 +1004,16 @@ def _evaluate(node: 'tuple', context: 'dict[str, object]') -> 'object': raise UnsupportedExpression(f'no evaluation rule for {node!r}') # pragma: no cover +def evaluate_expression(expression: 'str', context: 'dict[str, object]') -> 'object': + """The value ``expression`` evaluates to in ``context``, not only its truthiness. + + What a ``with:`` input receives, such as #1634's ``token:``, whose ``||`` + returns an operand rather than a boolean. + + """ + return _evaluate(parse_condition(expression), context) + + def evaluate_condition(condition: 'str', context: 'dict[str, object]') -> 'bool': """Whether a job carrying this ``if:`` would run in ``context``. @@ -1009,7 +1021,7 @@ def evaluate_condition(condition: 'str', context: 'dict[str, object]') -> 'bool' be checked -- as opposed to the text that produced it. """ - return _truthy(_evaluate(parse_condition(condition), context)) + return _truthy(evaluate_expression(condition, context)) #: The four values a job's ``needs..result`` can take. The axis #962 is @@ -1051,6 +1063,11 @@ def evaluate_condition(condition: 'str', context: 'dict[str, object]') -> 'bool' #: skipped gate admitted here would publish untested. STRICT_PREDECESSORS = ('version_check', 'unit-tests') +#: ``github.actor_id`` of a person, and ``vars.RELEASE_APP_BOT_ID``, the release +#: App's bot (#1634). Made up: only whether the two are equal matters. +HUMAN_ACTOR_ID = '1000001' +RELEASE_APP_BOT_ID = '2000002' + def release_context(version_check: 'str' = 'success', unit_tests: 'str' = 'success', @@ -1062,7 +1079,9 @@ def release_context(version_check: 'str' = 'success', cancelled: 'bool' = False, outputs_present: 'bool' = True, event_name: 'str' = 'workflow_run', - workflow_run_conclusion: 'str' = 'success') -> 'dict[str, object]': + workflow_run_conclusion: 'str' = 'success', + actor_id: 'str' = HUMAN_ACTOR_ID, + release_app_bot_id: 'str' = RELEASE_APP_BOT_ID) -> 'dict[str, object]': """The ``needs``/``github``/status context one scenario row stands for. Every default is a value that, on its own, lets a gate run, so a row @@ -1093,6 +1112,10 @@ def release_context(version_check: 'str' = 'success', ``v1.5.0`` is the other real value, from this workflow's ``push: tags: ['v*']`` trigger. + ``actor_id`` is a person's by default, and the release App's bot id is set + (#1634). ``release_app_bot_id=''`` is the variable unset, which the + ``vars`` docs give as "an empty string", not ``null``. + """ outputs = {} # type: dict[str, object] if outputs_present: @@ -1115,8 +1138,10 @@ def release_context(version_check: 'str' = 'success', 'github': { 'ref_name': ref_name, 'event_name': event_name, + 'actor_id': actor_id, 'event': {'workflow_run': {'conclusion': workflow_run_conclusion}}, }, + 'vars': {'RELEASE_APP_BOT_ID': release_app_bot_id}, STATUS: {'cancelled': cancelled}, } @@ -2110,8 +2135,8 @@ def test_evidence_gated_jobs_survive_a_legitimately_skipped_predecessor(self) -> Each dependency has to be checked for ``== 'success' || == 'skipped'`` explicitly, not merely ``!= 'failure'``: the latter still admits ``cancelled``, and a cancelled ``tag`` would send ``conda`` into its - ``actions/checkout`` with ``ref: conda-+0`` for a tag ``tag`` - never pushed -- a checkout failure standing in for a clean skip, + check that ``conda-+0`` names this run's commit (#1634), for a + tag ``tag`` never pushed -- a failed step standing in for a clean skip, found on the same cross-review as the two blocking defects above. """ @@ -2350,9 +2375,20 @@ class TestGateExpressionsEvaluateCorrectly(WorkflowMixin, unittest.TestCase): #: is the workflow's outermost gate -- held by ``unit-tests`` until #1052 #: moved ``version_check`` ahead of it: a ``workflow_run`` trigger only #: proceeds when the Vendor Update run that triggered it concluded - #: ``success``, and every other trigger proceeds unconditionally. + #: ``success``, and every other trigger proceeds -- #1634 -- unless it is + #: the ``v*`` push the release App's token makes, whose run would rerun + #: the gate and ask for a second approval. A person's push, and a + #: ``workflow_run`` whatever its actor, still run. VERSION_CHECK_ROWS = [ ('a v* tag push', release_context(event_name='push'), True), + ("a v* tag push by the release App's bot", + release_context(event_name='push', actor_id=RELEASE_APP_BOT_ID), False), + ('a v* tag push, no release App set up', + release_context(event_name='push', release_app_bot_id=''), True), + ("Vendor Update succeeded, the actor the release App's bot", + release_context(actor_id=RELEASE_APP_BOT_ID), True), + ("the App's bot, but Vendor Update found nothing to update", + release_context(actor_id=RELEASE_APP_BOT_ID, workflow_run_conclusion='skipped'), False), ('Vendor Update succeeded', release_context(), True), ('Vendor Update found nothing to update', release_context(workflow_run_conclusion='skipped'), False), @@ -3006,6 +3042,8 @@ def _run(self, overrides: 'dict[str, str]') -> 'subprocess.CompletedProcess[str] env = { 'PCAPKIT_EVENT_NAME': 'workflow_run', 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': 'success', + 'PCAPKIT_ACTOR_ID': HUMAN_ACTOR_ID, + 'PCAPKIT_RELEASE_APP_BOT_ID': RELEASE_APP_BOT_ID, 'PCAPKIT_UNIT_TESTS': 'success', 'PCAPKIT_VERSION_CHECK': 'success', 'PCAPKIT_GITHUB_JOB': 'success', @@ -3042,6 +3080,33 @@ def _run(self, overrides: 'dict[str, str]') -> 'subprocess.CompletedProcess[str] '::warning title=Release skipped', 0, ), + ( + # #1634: the tag the release App's token made starts a push run + # that version_check skips by design, which is routine. + 'release_app_pushed_the_tag', + {'PCAPKIT_VERSION_CHECK': 'skipped', 'PCAPKIT_UNIT_TESTS': 'skipped', + 'PCAPKIT_EVENT_NAME': 'push', 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': '', + 'PCAPKIT_ACTOR_ID': RELEASE_APP_BOT_ID}, + '::notice title=Nothing to release', + 0, + ), + ( + # A person's push that version_check skipped is still unexpected. + 'a_persons_tag_push_was_skipped', + {'PCAPKIT_VERSION_CHECK': 'skipped', 'PCAPKIT_UNIT_TESTS': 'skipped', + 'PCAPKIT_EVENT_NAME': 'push', 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': ''}, + '::warning title=Release skipped for an unexpected reason', + 0, + ), + ( + # With no App set up, an empty actor id matches nothing. + 'no_release_app_and_no_actor', + {'PCAPKIT_VERSION_CHECK': 'skipped', 'PCAPKIT_UNIT_TESTS': 'skipped', + 'PCAPKIT_EVENT_NAME': 'push', 'PCAPKIT_WORKFLOW_RUN_CONCLUSION': '', + 'PCAPKIT_ACTOR_ID': '', 'PCAPKIT_RELEASE_APP_BOT_ID': ''}, + '::warning title=Release skipped for an unexpected reason', + 0, + ), ( 'version_check_failed', { @@ -3143,6 +3208,16 @@ def _run(self, overrides: 'dict[str, str]') -> 'subprocess.CompletedProcess[str] ), ] + def test_the_release_apps_tag_push_is_quiet(self) -> None: + """#1634: a notice naming the App, and no warning at all.""" + overrides = dict(next(row[1] for row in self.VECTORS if row[0] == 'release_app_pushed_the_tag')) + result = self._run(overrides) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines(), [ + "::notice title=Nothing to release::the release App's bot pushed this tag from the Create " + "Release run that released its commit, so this run correctly released nothing (#1634).", + ]) + def test_each_scenario_takes_the_right_branch(self) -> None: for name, overrides, expected_marker, expected_rc in self.VECTORS: with self.subTest(scenario=name): diff --git a/tests/project/test_release_tags_the_run_sha_1634.py b/tests/project/test_release_tags_the_run_sha_1634.py new file mode 100644 index 000000000..8e730f477 --- /dev/null +++ b/tests/project/test_release_tags_the_run_sha_1634.py @@ -0,0 +1,1407 @@ +# -*- coding: utf-8 -*- +"""Create Release tags and builds exactly the commit its run was created for (#1634). + +Run 38008819343 was created for ``b6b95bc8e`` ("Bumped version to 1.5.0b9"), +waited three days on the ``github-release`` approval, and then tagged ``main``'s +tip ``2c0008c3c`` as both ``v1.5.0b9`` and ``conda-1.5.0b9+0``. Two moving refs +did it: ``softprops/action-gh-release`` defaults ``target_commitish`` to the +default branch, and the ``tag`` job ran ``git pull --rebase origin main`` -- +fast-forwarding ``b6b95bc8e..2c0008c3c`` in job 115087308551 -- before tagging. +``pypi`` and ``conda`` then checked those tags out, so the build-0 packages +carried ``2c0008c3c``'s pins, whose ``conda/build`` reads 3. + +The owner's ruling: tag and build exactly the run's commit, with the +``conda/build`` reset as a commit of its own on top. ``github.sha`` is that +commit on both triggers -- the pushed tag's commit, or ``main``'s tip when a +``workflow_run`` run was created -- whereas ``github.event.workflow_run.head_sha`` +is the commit Vendor Update *ran on*, before it pushed its bump. + +Asserted here, over :file:`.github/workflows/create-release.yml`: + +* every ``actions/checkout`` pins ``ref: ${{ github.sha }}``, and ``github``'s + ``action-gh-release`` names it as ``target_commitish`` only on + ``workflow_run``, where it creates the tag; +* ``pypi`` and ``conda`` attach their files with ``gh release upload`` rather + than ``action-gh-release``, which calls Update a release -- where GitHub + re-checks that target -- before every upload, and leave a file already on the + release alone (#1640) unless its asset is not ``uploaded``, which is deleted + and uploaded again; their shared script is run here against a stand-in + ``gh``; +* :file:`cron-conda.yml`'s ``conda-dist`` updates the release only when one is + published, and names the default branch as its target, so it neither creates + the pending version's tag at ``main``'s tip nor has a stale target re-checked; +* ``tag`` tags before anything looks at ``main``, never pulls, merges, rebases + or force-pushes, and its two scripts -- run here against a scratch repository + -- tag the run's commit, then reset ``conda/build`` on ``main``'s tip unless a + build number that would free is already tagged; +* ``github``, ``pypi`` and ``conda`` refuse to publish under a tag that names any + other commit, which is likewise run, not just read; +* every step handed ``PCAPKIT_RUN_SHA`` is handed ``github.sha``, and no + expression names ``workflow_run.head_sha`` -- the scratch-repository tests + inject their own values, so these are what tie them to the job; +* ``github`` creates the release with a token of the release App, minted with + Contents and Workflows write from a ``github-release`` environment secret + only that job reads, and spends ``GITHUB_TOKEN`` while no App is set up; + ``version_check`` skips the tag-push run the App's tag starts, and + ``release_status`` reports that skip quietly -- the owner's choice on #1634. + +The scanner is indentation-based so the module runs without the ``test`` extra; +:class:`TestYAMLAgreesWithTheScanner` checks it against :func:`yaml.safe_load` +where PyYAML is installed, the split ``test_release_gates.py`` uses. + +""" + +from __future__ import annotations + +import os +import pathlib +import re +import shutil +import subprocess +import tempfile +import unittest +from typing import TYPE_CHECKING + +from tests._support import scale_timeout +from tests.project import test_release_gates as gates +from tests.project.test_release_gates import (declared_environment, declared_if, evaluate_condition, + evaluate_expression, job_blocks, strip_comments) + +if TYPE_CHECKING: + from typing import Any, Optional + +ROOT = pathlib.Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / '.github' / 'workflows' / 'create-release.yml' +CONDA_WORKFLOW = ROOT / '.github' / 'workflows' / 'cron-conda.yml' + +#: The one ref every checkout and every release tag must name. +RUN_SHA = '${{ github.sha }}' + +#: ``github``'s ``target_commitish``: the run's commit where the step creates the +#: tag, and ``''`` -- unset, to the action -- on a ``v*`` push, where it exists. +RELEASE_TARGET = "${{ github.event_name == 'workflow_run' && github.sha || '' }}" + +#: The version ``version_check`` read from that commit. +VERSION_OUTPUT = '${{ needs.version_check.outputs.PCAPKIT_VERSION }}' + +#: The step ``github``, ``pypi`` and ``conda`` each run before publishing. +CHECK_STEP = "Check the release tags name this run's commit" + +#: ``github``'s step minting the release App's token, and its inputs, exactly. +MINT_STEP = "Mint the release App's token" +MINT_ACTION = 'actions/create-github-app-token@v3' +MINT_WITH = { + 'client-id': '${{ vars.RELEASE_APP_CLIENT_ID }}', + 'private-key': '${{ secrets.RELEASE_APP_PRIVATE_KEY }}', + 'permission-contents': 'write', + 'permission-workflows': 'write', +} + +#: Create Release's ``token``: the App's where it was minted, else ``GITHUB_TOKEN``. +RELEASE_TOKEN = '${{ steps.app_token.outputs.token || secrets.GITHUB_TOKEN }}' + +#: The step explaining a refused Create Release. +EXPLAIN_STEP = 'Explain a refused release tag' + +#: A read of the App's private key, as ``secrets.X`` or ``secrets['X']``. +SECRET = re.compile(r"""secrets\s*(?:\.\s*|\[\s*['"])RELEASE_APP_PRIVATE_KEY\b""") + +#: The step ``pypi`` and ``conda`` attach their files to the GitHub Release with. +UPLOAD_STEP = 'Upload package to GitHub Release' + +#: Its ``env:`` in each job: a glob in ``pypi``, ``conda_build``'s list in ``conda``. +UPLOAD_ENV = { + job: {'GH_TOKEN': '${{ secrets.GITHUB_TOKEN }}', 'PCAPKIT_RELEASE_TAG': f'v{VERSION_OUTPUT}', + 'PCAPKIT_ASSETS': assets} + for job, assets in (('pypi', 'dist/*'), ('conda', '${{ steps.conda_build.outputs.packages }}')) +} + +#: ``tag``'s two scripts, in the order the job runs them. +TAG_STEPS = ('Create Tag', 'Reset Build Number') + +#: Steps whose ``env:`` names ``PCAPKIT_RUN_SHA``, so the scan below cannot pass vacuously. +RUN_SHA_STEPS = { + 'github': [CHECK_STEP, EXPLAIN_STEP], + 'tag': list(TAG_STEPS), + 'pypi': [CHECK_STEP], + 'conda': [CHECK_STEP], +} + +#: Expressions naming another run's commit: ``workflow_run.head_sha`` is the +#: commit Vendor Update ran *on*, ``945c3c3d8`` for run 38008819343, not its bump. +UPSTREAM_COMMIT = re.compile(r'\bworkflow_run\.head_|\bhead_commit\b') + +#: The jobs that check out the repository, which is every job with steps but +#: ``release_status``. +CHECKOUT_JOBS = ('version_check', 'github', 'tag', 'pypi', 'conda') + +#: Git commands that move a checkout onto ``main``'s history or rewrite it. +#: ``merge-base`` is a read, so it is excluded from ``git merge``. +FORBIDDEN_GIT = { + 'git pull': re.compile(r'\bgit\s+pull\b'), + 'git merge': re.compile(r'\bgit\s+merge\b(?!-base)'), + 'git rebase': re.compile(r'\bgit\s+rebase\b'), + 'git reset': re.compile(r'\bgit\s+reset\b'), + 'a forced git push': re.compile(r'\bgit\s+push\b[^\n]*(?:--force|\s-f\b|\s"?\+)'), +} + +_JOB = re.compile(r'^ ([A-Za-z_][A-Za-z0-9_-]*):[ \t]*(?:#.*)?$') +_STEP = re.compile(r'^ - (.*)$') +_KEY = {indent: re.compile(r'^ {%d}([A-Za-z_][A-Za-z0-9_-]*):(?:[ \t]+(.*))?$' % indent) + for indent in (8, 10)} + + +def _indent(line: 'str') -> 'int': + return len(line) - len(line.lstrip(' ')) + + +def _scalar(value: 'str') -> 'str': + """A one-line YAML value without its trailing comment or surrounding quotes.""" + value = re.sub(r'\s+#.*$', '', value).strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in '"\'': + value = value[1:-1] + return value + + +def parse_step(lines: 'list[str]') -> 'dict[str, Any]': + """One step's keys: scalars as strings, ``with:``/``env:`` as dicts, ``run: |`` as text.""" + step = {} # type: dict[str, Any] + index = 0 + while index < len(lines): + match = _KEY[8].match(lines[index]) + index += 1 + if match is None or lines[index - 1].strip().startswith('#'): + continue + key, value = match.group(1), (match.group(2) or '').strip() + if value in ('|', '|-'): + body = [] # type: list[str] + while index < len(lines) and (not lines[index].strip() or _indent(lines[index]) >= 10): + body.append(lines[index][10:] if lines[index].strip() else '') + index += 1 + while body and not body[-1]: + body.pop() + step[key] = '\n'.join(body) + '\n' + elif not value or value.startswith('#'): + mapping = {} # type: dict[str, str] + while index < len(lines) and (not lines[index].strip() or _indent(lines[index]) >= 10): + child = _KEY[10].match(lines[index]) + if child is not None and not lines[index].strip().startswith('#'): + mapping[child.group(1)] = _scalar(child.group(2) or '') + index += 1 + step[key] = mapping + else: + step[key] = _scalar(value) + return step + + +def workflow_steps(text: 'str') -> 'dict[str, list[dict[str, Any]]]': + """``{job: [step, ...]}`` for every job of a workflow, in file order.""" + lines = text.splitlines() + jobs = {} # type: dict[str, list[str]] + current = None # type: Optional[str] + for line in lines[lines.index('jobs:') + 1:]: + if line.strip() and not line.strip().startswith('#') and not line.startswith(' '): + break + match = _JOB.match(line) + if match is not None: + current = match.group(1) + jobs[current] = [] + elif current is not None: + jobs[current].append(line) + + result = {} # type: dict[str, list[dict[str, Any]]] + for job, body in jobs.items(): + items = [] # type: list[list[str]] + if ' steps:' in body: + for line in body[body.index(' steps:') + 1:]: + if line.strip() and not line.strip().startswith('#') and _indent(line) <= 4: + break + match = _STEP.match(line) + if match is not None: + items.append([' ' * 8 + match.group(1)]) + elif items: + items[-1].append(line) + result[job] = [parse_step(item) for item in items] + return result + + +def label(step: 'dict[str, Any]') -> 'str': + return step.get('name') or step.get('uses') or '' + + +class WorkflowMixin: + """Reads and scans :attr:`workflow` once per class.""" + + workflow = WORKFLOW + steps = {} # type: dict[str, list[dict[str, Any]]] + + @classmethod + def setUpClass(cls) -> None: + cls.steps = workflow_steps(cls.workflow.read_text(encoding='utf-8')) + + def step(self, job: 'str', name: 'str') -> 'dict[str, Any]': + found = [step for step in self.steps[job] if step.get('name') == name] + if len(found) != 1: + message = f'{job} has {len(found)} steps named {name!r}, expected exactly one' + self.fail(message) # type: ignore[attr-defined] + return found[0] + + def index(self, job: 'str', name: 'str') -> 'int': + return self.steps[job].index(self.step(job, name)) + + +class TestEveryCheckoutPinsTheRunSha(WorkflowMixin, unittest.TestCase): + """No job builds, tags or reads a version from a ref that can move.""" + + def checkouts(self) -> 'dict[str, list[dict[str, Any]]]': + return {job: [step for step in steps if step.get('uses', '').startswith('actions/checkout@')] + for job, steps in self.steps.items()} + + def test_the_scan_found_every_checkout(self) -> None: + found = {job: len(steps) for job, steps in self.checkouts().items() if steps} + self.assertEqual(found, dict.fromkeys(CHECKOUT_JOBS, 1)) + + def test_every_checkout_pins_the_run_sha(self) -> None: + """``tag``'s used no ``ref:``, ``pypi``'s ``v``, ``conda``'s ``conda-+0``.""" + for job, steps in sorted(self.checkouts().items()): + for step in steps: + with self.subTest(job=job): + self.assertEqual( + step.get('with', {}).get('ref'), RUN_SHA, + f'{job} checks out {step.get("with", {}).get("ref")!r} rather than ' + f'the commit this run was created for', + ) + + +class TestEveryReleaseTagIsCreatedAtTheRunSha(WorkflowMixin, unittest.TestCase): + """``action-gh-release`` creates a missing tag at ``target_commitish``, default ``main``.""" + + def releases(self) -> 'list[tuple[str, dict[str, Any]]]': + return [(job, step) for job, steps in sorted(self.steps.items()) for step in steps + if step.get('uses', '').startswith('softprops/action-gh-release@')] + + def test_only_github_runs_the_release_action(self) -> None: + """``pypi`` and ``conda`` used it to upload, which re-checked the target. + + ``action-gh-release`` 3.0.2 calls Update a release on an existing + release before uploading anything (``src/github.ts:631-683``), passing + the release's own ``target_commitish`` when the input is unset, and + GitHub's REST docs apply the workflow-file rule there to "the new value + of target_commitish if you are changing it, otherwise the existing + target". On ``workflow_run`` that target is ``github.sha``, so by the + docs a workflow change reaching ``main`` after ``github`` ran would have + failed these uploads after PyPI and anaconda.org had published. + """ + self.assertEqual([(job, label(step)) for job, step in self.releases()], [('github', 'Create Release')]) + + def test_the_tag_creating_release_names_the_run_sha_on_workflow_run_only(self) -> None: + """Create a release applies the workflow-file rule to ``target_commitish`` + or, when it is omitted, the default branch's latest commit. + + On a ``v*`` push the tag exists, a target adds nothing the tag check does + not prove, and naming one would expose the hand-pushed recovery tag to + that rule. + """ + (_, step), = self.releases() + self.assertEqual(step.get('with', {}).get('target_commitish'), RELEASE_TARGET) + + def test_a_refused_tag_is_explained(self) -> None: + """GitHub refuses ``GITHUB_TOKEN`` a target that differs from ``main`` in a workflow file. + + The REST docs give 404 Not Found, or on some paths 403, and the step + fires on any Create Release failure, so it names both. Which token was + spent decides the fix, so it reads the mint step's outcome; + :class:`TestTheRefusalExplanation` runs both branches. + """ + step = self.step('github', EXPLAIN_STEP) + self.assertEqual(step.get('if'), "${{ failure() && steps.create_release.outcome == 'failure' }}") + self.assertEqual(step.get('env'), {'PCAPKIT_VERSION': VERSION_OUTPUT, 'PCAPKIT_RUN_SHA': RUN_SHA, + 'PCAPKIT_APP_TOKEN_OUTCOME': '${{ steps.app_token.outcome }}'}) + for status in ('404 Not Found', "403 'Resource not accessible by integration'"): + with self.subTest(status=status): + self.assertIn(status, step['run']) + # A final release is created as a draft and published by Update a release + # in the same step (``src/github.ts:1025``); a re-run would find a draft + # left behind and keep its target (``:636-647``). + self.assertIn('delete any draft release', step['run']) + self.assertGreater(self.index('github', EXPLAIN_STEP), + self.steps['github'].index(next( + s for s in self.steps['github'] if s.get('id') == 'create_release'))) + + +class TestTheReleaseAppToken(WorkflowMixin, unittest.TestCase): + """#1634, the owner's choice: Create Release spends a GitHub App installation token. + + Create a release's REST docs: for a target that "adds or modifies any file + under .github/workflows/ relative to the repository's default branch", + "GitHub App installation tokens also need the "Workflows" repository + permission (write). The GITHUB_TOKEN available to GitHub Actions cannot be + authorized for this". ``actions/create-github-app-token`` v3.3.0, which + ``v3`` names: ``client-id`` "GitHub App Client ID", ``private-key`` + required, one ``permission-`` input each, ``permission-workflows`` + "Can be set to 'write'" (``action.yml:8-17,51-52,147-148``), and output + ``token``. With no ``owner`` or ``repositories``, "access will be scoped to + only the current repository", and the token "is revoked in the ``post`` + step of the action, which means it cannot be passed to another job" + (``README.md:389,436``). Without a ``permission-*`` input "the token + inherits all of the installation's permissions" (``:402``), so both are + named and nothing else is. + """ + + def create_release(self) -> 'dict[str, Any]': + return next(step for step in self.steps['github'] if step.get('id') == 'create_release') + + def test_github_mints_with_contents_and_workflows_write_only(self) -> None: + step = self.step('github', MINT_STEP) + self.assertEqual(step.get('uses'), MINT_ACTION) + self.assertEqual(step.get('id'), 'app_token') + self.assertEqual(step.get('with'), MINT_WITH) + + def test_it_is_minted_straight_before_create_release(self) -> None: + """Nothing runs between minting and spending the token, which lasts an hour.""" + self.assertEqual(self.index('github', MINT_STEP) + 1, self.steps['github'].index(self.create_release())) + + def test_only_create_release_spends_it(self) -> None: + """The ``token:`` input, with no ``env:`` beside it: 3.0.2's ``parseToken`` + prefers a non-empty ``INPUT_TOKEN`` and falls back to ``GITHUB_TOKEN`` + (``src/util.ts:107-113``), so an ``env:`` token would only ever be a + second credential handed to the action.""" + step = self.create_release() + self.assertEqual(step.get('with', {}).get('token'), RELEASE_TOKEN) + self.assertNotIn('env', step) + for path in sorted(WORKFLOW.parent.glob('*.yml')): + lines = strip_comments(path.read_text(encoding='utf-8')).splitlines() + found = [line.strip() for line in lines if 'steps.app_token.outputs' in line] + with self.subTest(workflow=path.name): + self.assertEqual(found, [f'token: {RELEASE_TOKEN}'] if path == WORKFLOW else []) + + def test_without_the_variable_nothing_is_minted_and_github_token_is_spent(self) -> None: + """An unset variable reads as "an empty string" (the ``vars`` context docs). + + A skipped step's ``outputs`` hold nothing, which is ``null`` or ``''``. + """ + step = self.step('github', MINT_STEP) + token = self.create_release()['with']['token'] + self.assertFalse(evaluate_condition(step['if'], {'vars': {'RELEASE_APP_CLIENT_ID': ''}})) + self.assertTrue(evaluate_condition(step['if'], {'vars': {'RELEASE_APP_CLIENT_ID': 'Iv23example'}})) + secrets = {'GITHUB_TOKEN': 'the run token'} + for outputs in ({}, {'token': ''}): + with self.subTest(outputs=outputs): + context = {'steps': {'app_token': {'outputs': outputs}}, 'secrets': secrets} + self.assertEqual(evaluate_expression(token, context), 'the run token') + minted = {'steps': {'app_token': {'outputs': {'token': 'the App token'}}}, 'secrets': secrets} + self.assertEqual(evaluate_expression(token, minted), 'the App token') + + def test_the_private_key_is_read_only_in_github_behind_its_environment(self) -> None: + """An environment secret: "If the environment requires approval, a job cannot + access environment secrets until one of the required reviewers approves it".""" + for path in sorted(WORKFLOW.parent.glob('*.yml')): + text = strip_comments(path.read_text(encoding='utf-8')) + readers = sorted(job for job, block in job_blocks(text, path).items() if SECRET.search(block)) + with self.subTest(workflow=path.name): + self.assertEqual(readers, ['github'] if path == WORKFLOW else []) + self.assertNotRegex(text, r'(?i)toJSON\(\s*secrets\s*\)', 'every secret, this one included') + uses = [label(step) for step in self.steps['github'] if SECRET.search(repr(step))] + self.assertEqual(uses, [MINT_STEP]) + self.assertEqual(declared_environment(job_blocks(WORKFLOW.read_text(encoding='utf-8'))['github']), + 'github-release') + + +class TestTheAppsTagPushReleasesNothing(WorkflowMixin, unittest.TestCase): + """The tag the App's token creates starts a run, unlike ``GITHUB_TOKEN``'s + ("events triggered by the ``GITHUB_TOKEN`` ... will not create a new + workflow run"). ``version_check`` skips it and ``release_status`` says why. + + ``github.actor_id`` is "The account ID of the person or app that triggered + the initial workflow run"; whether this push carries the bot's is + unmeasured, and if it does not the guard never matches. + """ + + GUARD = "!(github.event_name == 'push' && github.actor_id == vars.RELEASE_APP_BOT_ID)" + TRIGGER = "(github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success')" + + #: Edits a review round could make, each of which some row must catch. + MUTATIONS = { + 'the guard is dropped': (f' && {GUARD}', ''), + 'the trigger clause loses its parentheses, so the guard binds to its right half only': + (TRIGGER, TRIGGER[1:-1]), + 'the guard skips every bot run, not only a push': ("github.event_name == 'push' && github.actor_id", + 'github.actor_id'), + 'the guard is not negated': (GUARD, GUARD[1:]), + } + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.condition = declared_if(job_blocks(WORKFLOW.read_text(encoding='utf-8'))['version_check']) + + def runs(self, condition: 'str', **context: 'str') -> 'bool': + return evaluate_condition(condition, gates.release_context(**context)) # type: ignore[arg-type] + + def test_the_apps_push_skips_and_everything_else_runs(self) -> None: + bot = gates.RELEASE_APP_BOT_ID + self.assertFalse(self.runs(self.condition, event_name='push', actor_id=bot)) + self.assertTrue(self.runs(self.condition, event_name='push')) + self.assertTrue(self.runs(self.condition, event_name='push', release_app_bot_id='')) + self.assertTrue(self.runs(self.condition, event_name='workflow_run', actor_id=bot)) + + def test_every_mutation_changes_a_version_check_row(self) -> None: + rows = gates.TestGateExpressionsEvaluateCorrectly.VERSION_CHECK_ROWS + for name, (old, new) in sorted(self.MUTATIONS.items()): + with self.subTest(mutation=name): + self.assertIn(old, self.condition) + mutant = self.condition.replace(old, new, 1) + self.assertTrue([row for row, context, expected in rows + if evaluate_condition(mutant, context) != expected], mutant) + + def test_release_status_reads_what_version_check_compares(self) -> None: + """The quiet notice is the guard's own test, so the two must not drift apart.""" + env = self.steps['release_status'][0]['env'] + self.assertEqual(env.get('PCAPKIT_EVENT_NAME'), '${{ github.event_name }}') + self.assertEqual(env.get('PCAPKIT_ACTOR_ID'), '${{ github.actor_id }}') + self.assertEqual(env.get('PCAPKIT_RELEASE_APP_BOT_ID'), '${{ vars.RELEASE_APP_BOT_ID }}') + script = self.steps['release_status'][0]['run'] + self.assertIn('[ "$PCAPKIT_EVENT_NAME" = "push" ] && [ -n "$PCAPKIT_RELEASE_APP_BOT_ID" ]', script) + self.assertIn('[ "$PCAPKIT_ACTOR_ID" = "$PCAPKIT_RELEASE_APP_BOT_ID" ]', script) + + +class TestTagNeverMovesOntoMain(WorkflowMixin, unittest.TestCase): + """``tag`` names the run's commit and touches ``main`` only to add the reset on top.""" + + def scripts(self) -> 'list[tuple[str, str]]': + return [(label(step), step['run']) for step in self.steps['tag'] if 'run' in step] + + def test_no_step_pulls_merges_rebases_or_force_pushes(self) -> None: + for name, script in self.scripts(): + for what, pattern in sorted(FORBIDDEN_GIT.items()): + with self.subTest(step=name, forbidden=what): + self.assertIsNone(pattern.search(script), f'{name!r} runs {what}') + + def test_the_scripts_are_handed_the_run_sha_and_its_version(self) -> None: + """The scratch-repository tests inject this env themselves; only this reads the job's.""" + for name in TAG_STEPS: + with self.subTest(step=name): + self.assertEqual(self.step('tag', name).get('env'), + {'PCAPKIT_VERSION': VERSION_OUTPUT, 'PCAPKIT_RUN_SHA': RUN_SHA}) + + def test_the_conda_tag_names_the_run_commit(self) -> None: + step = self.step('tag', 'Create Tag') + self.assertEqual(step.get('env', {}).get('PCAPKIT_RUN_SHA'), RUN_SHA) + self.assertIn('run_commit="$(git rev-parse --verify "${PCAPKIT_RUN_SHA}^{commit}")"', step['run']) + tagging = [line for line in step['run'].splitlines() if re.search(r'\bgit\s+tag\b', line)] + self.assertEqual(len(tagging), 1, tagging) + self.assertTrue(tagging[0].rstrip().endswith('"$run_commit"'), tagging[0]) + + def test_nothing_before_the_tag_reads_main(self) -> None: + """The defect was a fast-forward onto ``main`` *before* ``git tag`` ran.""" + upto = self.index('tag', 'Create Tag') + for step in self.steps['tag'][:upto + 1]: + with self.subTest(step=label(step)): + self.assertNotRegex(step.get('run', ''), r'\bmain\b') + + def test_the_scripts_take_their_inputs_through_env(self) -> None: + """No ``${{ }}`` inside a script, which is also what lets the tests below run them.""" + for name, script in self.scripts(): + with self.subTest(step=name): + self.assertNotIn('${{', script) + + def test_the_configure_step_only_configures(self) -> None: + """The scratch-repository tests skip this step, so it must hold nothing else.""" + self.assertEqual(self.step('tag', 'Configure git for the deploy key')['run'].splitlines(), [ + 'git remote set-url origin git@github.com:JarryShaw/PyPCAPKit.git', + 'git config --local user.email "github-actions[bot]@users.noreply.github.com"', + 'git config --local user.name "github-actions[bot]"', + ]) + + +class TestPublishersCheckTheirTags(WorkflowMixin, unittest.TestCase): + """``github``, ``pypi`` and ``conda`` refuse a tag that names another commit.""" + + EXPECTED_TAGS = { + 'github': f'v{VERSION_OUTPUT}', + 'pypi': f'v{VERSION_OUTPUT}', + 'conda': f'v{VERSION_OUTPUT} conda-{VERSION_OUTPUT}+0', + } + + def test_each_publisher_checks_the_tags_it_publishes_under(self) -> None: + for job, tags in sorted(self.EXPECTED_TAGS.items()): + with self.subTest(job=job): + self.assertEqual(self.step(job, CHECK_STEP).get('env'), + {'PCAPKIT_TAGS': tags, 'PCAPKIT_RUN_SHA': RUN_SHA}) + + def test_the_check_is_one_script(self) -> None: + scripts = {job: self.step(job, CHECK_STEP)['run'] for job in self.EXPECTED_TAGS} + self.assertEqual(len(set(scripts.values())), 1, 'the three copies have drifted apart') + + def test_the_check_blocks_every_leg(self) -> None: + """#1633's KTLO legs carry ``continue-on-error`` on every other step; this one must not. + + It fails alike on every leg, and with ``continue-on-error`` a KTLO leg + would run its later steps and publish under a tag naming another commit. + """ + for job in sorted(self.EXPECTED_TAGS): + with self.subTest(job=job): + self.assertNotIn('continue-on-error', self.step(job, CHECK_STEP)) + + def test_the_builders_check_before_anything_else(self) -> None: + """Straight after the checkout, so nothing is built, let alone uploaded, first.""" + for job in ('pypi', 'conda'): + with self.subTest(job=job): + self.assertTrue(self.steps[job][0].get('uses', '').startswith('actions/checkout@')) + self.assertEqual(self.index(job, CHECK_STEP), 1) + + def test_github_checks_the_tag_it_just_created(self) -> None: + create = next(i for i, s in enumerate(self.steps['github']) if s.get('id') == 'create_release') + self.assertEqual(self.index('github', CHECK_STEP), create + 1) + + +class TestUploadsStayOffUpdateARelease(WorkflowMixin, unittest.TestCase): + """``pypi`` and ``conda`` attach files through the release-assets endpoint only. + + GitHub's REST docs state the workflow-file rule for Create a release and + Update a release, and none for Upload a release asset. ``gh release upload`` + reads the release, then POSTs each file to its ``upload_url`` + (cli/cli 2.102.0, ``pkg/cmd/release/upload/upload.go:89-116``). Without + ``--clobber`` it refuses a name the release already has (``:94-108``); with + it, it deletes that asset first (``pkg/cmd/release/shared/upload.go:141-146``), + which Delete a release asset's docs state no workflow-file rule for either. + """ + + def test_each_upload_is_a_script_with_its_inputs_in_env(self) -> None: + for job, env in sorted(UPLOAD_ENV.items()): + with self.subTest(job=job): + step = self.step(job, UPLOAD_STEP) + self.assertNotIn('uses', step) + self.assertEqual(step.get('shell'), 'bash') + self.assertEqual(step.get('env'), env) + self.assertNotIn('${{', step['run']) + + def test_the_upload_is_one_script(self) -> None: + scripts = {job: self.step(job, UPLOAD_STEP)['run'] for job in UPLOAD_ENV} + self.assertEqual(len(set(scripts.values())), 1, 'the two copies have drifted apart') + + def test_it_only_reads_the_release_and_uploads(self) -> None: + """No ``gh`` call that could edit the release itself, and one ``--clobber``. + + That one replaces an asset whose ``state`` is not ``uploaded``, which + :class:`TestTheUploadScript` runs; nothing else may delete an asset. + """ + script = self.step('pypi', UPLOAD_STEP)['run'] + calls = re.findall(r'\bgh\s+(\S+(?:\s+\S+)?)', script) + self.assertEqual(sorted(set(calls)), ['release upload', 'release view']) + self.assertEqual(script.count('--clobber'), 1) + self.assertIn("--jq '.assets[] | [.name, .state] | @tsv'", script) + + def test_conda_uploads_whatever_check_leg_found(self) -> None: + """Only the Anaconda upload is gated per leg. + + A leg whose Anaconda upload landed and whose GitHub upload then failed + must still get its GitHub asset on a re-run; skipping files the release + already has is what stops the replacement #1640 measured. + """ + self.assertEqual(self.step('conda', UPLOAD_STEP).get('if'), "${{ matrix.os == 'ubuntu-latest' }}") + self.assertEqual(self.step('conda', 'Upload conda packages to Anaconda').get('if'), + "${{ steps.check_leg.outputs.PCAPKIT_LEG_EXISTS != 'true' }}") + + +class TestEveryScriptIsHandedTheRunSha(WorkflowMixin, unittest.TestCase): + """Whatever reads ``PCAPKIT_RUN_SHA`` reads ``github.sha``, in every job.""" + + def test_every_run_sha_is_github_sha(self) -> None: + found = {} # type: dict[str, list[str]] + for job, steps in sorted(self.steps.items()): + for step in steps: + value = step.get('env', {}).get('PCAPKIT_RUN_SHA') + if value is None: + continue + found.setdefault(job, []).append(label(step)) + with self.subTest(job=job, step=label(step)): + self.assertEqual(value, RUN_SHA) + self.assertEqual(found, RUN_SHA_STEPS) + + def test_every_script_reading_it_declares_it(self) -> None: + """Not inherited from a job- or workflow-level ``env:`` that the scan does not check.""" + for job, steps in sorted(self.steps.items()): + for step in steps: + if 'PCAPKIT_RUN_SHA' in step.get('run', ''): + with self.subTest(job=job, step=label(step)): + self.assertIn('PCAPKIT_RUN_SHA', step.get('env', {})) + + def test_no_expression_names_the_upstream_runs_commit(self) -> None: + text = WORKFLOW.read_text(encoding='utf-8') + for number, line in enumerate(text.splitlines(), 1): + if line.lstrip().startswith('#'): + continue + for expression in re.findall(r'\$\{\{(.*?)\}\}', line): + with self.subTest(line=number): + self.assertIsNone(UPSTREAM_COMMIT.search(expression), line.strip()) + + +GIT = shutil.which('git') or '' +BASH = shutil.which('bash') or '' + + +@unittest.skipIf(not GIT or not BASH, 'needs git and bash on PATH') +class ScratchRepository(WorkflowMixin, unittest.TestCase): + """An ``origin`` that refuses non-fast-forward pushes, a seed clone that writes + to it, and a ``work`` clone standing in for the job's detached checkout.""" + + VERSION = '1.5.0b9' + + def setUp(self) -> None: + self.tmp = pathlib.Path(tempfile.mkdtemp(prefix='pcapkit-1634-')) + self.addCleanup(shutil.rmtree, self.tmp, True) + self.env = { + 'PATH': os.environ.get('PATH', ''), 'HOME': str(self.tmp), 'LC_ALL': 'C', + 'GIT_CONFIG_GLOBAL': os.devnull, 'GIT_CONFIG_NOSYSTEM': '1', 'GIT_TERMINAL_PROMPT': '0', + 'GIT_AUTHOR_NAME': 'test', 'GIT_AUTHOR_EMAIL': 'test@example.invalid', + 'GIT_COMMITTER_NAME': 'test', 'GIT_COMMITTER_EMAIL': 'test@example.invalid', + } + self.origin = self.tmp / 'origin.git' + self.seed = self.tmp / 'seed' + self.work = self.tmp / 'work' + self.git('init', '-q', '--bare', '-b', 'main', str(self.origin)) + self.git('-C', str(self.origin), 'config', 'receive.denyNonFastForwards', 'true') + self.git('-C', str(self.origin), 'config', 'receive.denyDeletes', 'true') + self.git('init', '-q', '-b', 'main', str(self.seed)) + self.git('-C', str(self.seed), 'remote', 'add', 'origin', str(self.origin)) + + def git(self, *args: 'str') -> 'str': + result = subprocess.run([GIT, *args], env=self.env, capture_output=True, text=True, + check=False, timeout=scale_timeout(30)) + self.assertEqual(result.returncode, 0, f'git {" ".join(args)}: {result.stderr}') + return result.stdout.strip() + + def commit(self, version: 'str', build: 'str', note: 'str' = '') -> 'str': + """Commit ``__version__``/``conda/build`` (and a note) in the seed; return the sha.""" + (self.seed / 'pcapkit').mkdir(exist_ok=True) + (self.seed / 'conda').mkdir(exist_ok=True) + (self.seed / 'pcapkit' / '__init__.py').write_text(f"__version__ = '{version}'\n") + (self.seed / 'conda' / 'build').write_text(build) + (self.seed / 'NOTE').write_text(note) + self.git('-C', str(self.seed), 'add', '-A') + self.git('-C', str(self.seed), 'commit', '-q', '--allow-empty', '-m', f'{version} build {build} {note}') + return self.git('-C', str(self.seed), 'rev-parse', 'HEAD') + + def push(self, sha: 'str', ref: 'str') -> None: + self.git('-C', str(self.seed), 'push', '-q', 'origin', f'{sha}:{ref}') + + def remote(self, ref: 'str') -> 'Optional[str]': + result = subprocess.run([GIT, '-C', str(self.origin), 'rev-parse', '-q', '--verify', + f'{ref}^{{commit}}'], env=self.env, capture_output=True, text=True, + check=False, timeout=scale_timeout(30)) + return result.stdout.strip() or None + + def checkout(self, sha: 'str') -> None: + """What ``actions/checkout`` leaves: every branch and tag fetched, HEAD detached at ``sha``.""" + self.git('clone', '-q', str(self.origin), str(self.work)) + self.git('-C', str(self.work), 'fetch', '-q', 'origin', '+refs/tags/*:refs/tags/*') + self.git('-C', str(self.work), 'switch', '-q', '--detach', sha) + + def run_steps(self, job: 'str', names: 'tuple[str, ...]', env: 'dict[str, str]', + path: 'Optional[str]' = None) -> 'subprocess.CompletedProcess[str]': + """Run the named steps' scripts in order under ``bash -e``, stopping at a failure. + + ``env`` stands in for each step's own ``env:``, so it must name the same + variables; their values in the workflow are checked by the classes above. + """ + output = [] + result = None # type: Optional[subprocess.CompletedProcess[str]] + for name in names: + self.assertEqual(sorted(env), sorted(self.step(job, name).get('env', {})), name) + script = self.tmp / 'step.sh' + script.write_text(self.step(job, name)['run']) + step_env = dict(self.env, **env) + if path is not None: + step_env['PATH'] = path + os.pathsep + step_env['PATH'] + result = subprocess.run([BASH, '-e', str(script)], cwd=self.work, env=step_env, + capture_output=True, text=True, check=False, + timeout=scale_timeout(60)) + output.append(result.stdout + result.stderr) + if result.returncode: + break + assert result is not None + return subprocess.CompletedProcess(result.args, result.returncode, '\n'.join(output), '') + + +class TestTagJobScripts(ScratchRepository): + """``tag``'s "Create Tag" then "Reset Build Number", as the job runs them.""" + + STEPS = TAG_STEPS + + def release(self, run_sha: 'str', path: 'Optional[str]' = None) -> 'subprocess.CompletedProcess[str]': + return self.run_steps('tag', self.STEPS, {'PCAPKIT_VERSION': self.VERSION, 'PCAPKIT_RUN_SHA': run_sha}, + path) + + @property + def conda_tag(self) -> 'str': + return f'refs/tags/conda-{self.VERSION}+0' + + def released(self, run_sha: 'str') -> None: + """``github``'s half: the ``v*`` tag at the run's commit.""" + self.push(run_sha, f'refs/tags/v{self.VERSION}') + + def tag_builds(self, bumps: 'dict[str, str]') -> None: + """Conda Update's half: ``conda-+N`` at the bump that wrote N.""" + for build, sha in bumps.items(): + self.push(sha, f'refs/tags/conda-{self.VERSION}+{build}') + self.assertEqual(self.remote(f'refs/tags/conda-{self.VERSION}+{build}'), sha) + + def test_the_1634_shape_tags_the_run_commit_and_resets_mains_tip(self) -> None: + """Run 38008819343: a bump with build 0, then three Conda Update bumps + before approval, none tagged (#1632) -- origin has only ``conda-1.5.0b9+0``.""" + older = self.commit('1.5.0b8', '1') + self.push(older, 'refs/tags/conda-1.5.0b8+1') + run_sha = self.commit(self.VERSION, '0') + self.push(run_sha, 'refs/heads/main') + self.released(run_sha) + for build in ('1', '2', '3'): + tip = self.commit(self.VERSION, build) + self.push(tip, 'refs/heads/main') + self.checkout(run_sha) + + result = self.release(run_sha) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.remote(self.conda_tag), run_sha) + self.assertEqual(self.remote('refs/heads/main~1'), tip, 'the reset is not on top of the tip') + self.assertEqual(self.git('-C', str(self.origin), 'show', 'main:conda/build'), '0') + self.assertEqual(self.git('-C', str(self.origin), 'diff', '--name-only', tip, 'main'), 'conda/build') + self.assertIn('conda/build reset from 3 to 0 on main', result.stdout) + + def test_a_stale_counter_at_the_tip_is_reset_on_top_of_it(self) -> None: + """A hand-made version change that left the previous version's counter behind.""" + run_sha = self.commit(self.VERSION, '2') + self.push(run_sha, 'refs/heads/main') + self.released(run_sha) + self.checkout(run_sha) + + result = self.release(run_sha) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.remote(self.conda_tag), run_sha) + head = self.remote('refs/heads/main') + self.assertEqual(self.remote('refs/heads/main~1'), run_sha) + self.assertEqual(self.git('-C', str(self.origin), 'show', f'{head}:conda/build'), '0') + self.assertEqual(self.git('-C', str(self.origin), 'diff', '--name-only', run_sha, str(head)), 'conda/build') + + def test_the_reset_is_replayed_onto_a_tip_that_kept_the_counter(self) -> None: + run_sha = self.commit(self.VERSION, '2') + self.push(run_sha, 'refs/heads/main') + self.released(run_sha) + tip = self.commit(self.VERSION, '2', 'an unrelated change') + self.push(tip, 'refs/heads/main') + self.checkout(run_sha) + + result = self.release(run_sha) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.remote(self.conda_tag), run_sha) + self.assertEqual(self.remote('refs/heads/main~1'), tip) + self.assertEqual(self.git('-C', str(self.origin), 'show', 'main:conda/build'), '0') + self.assertEqual(self.git('-C', str(self.origin), 'show', 'main:NOTE'), 'an unrelated change') + + def test_a_counter_whose_builds_are_tagged_is_not_rewound(self) -> None: + """The 1634 shape once #1632 tags each bump: resetting would hand +1 out twice. + + The tags land after the checkout, so the decision reads origin, not the + tags ``actions/checkout`` fetched. + """ + run_sha = self.commit(self.VERSION, '0') + self.push(run_sha, 'refs/heads/main') + self.released(run_sha) + bumps = {build: self.commit(self.VERSION, build) for build in ('1', '2', '3')} + tip = bumps['3'] + self.push(tip, 'refs/heads/main') + self.checkout(run_sha) + self.tag_builds(bumps) + + result = self.release(run_sha) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.remote(self.conda_tag), run_sha) + self.assertEqual(self.remote('refs/heads/main'), tip, 'main was touched') + self.assertIn(f'::warning title=conda/build not reset::conda-{self.VERSION} is already tagged ' + f'at +1 +2 +3; resetting conda/build 3', result.stdout) + + def test_a_tagged_build_number_is_never_freed_at_the_run_commit(self) -> None: + """A ``v*`` tag pushed on a Conda Update bump: its counter is tagged, so it stays.""" + self.commit(self.VERSION, '0') + bumps = {build: self.commit(self.VERSION, build) for build in ('1', '2')} + run_sha = bumps['2'] + self.push(run_sha, 'refs/heads/main') + self.tag_builds(bumps) + self.released(run_sha) + self.checkout(run_sha) + + result = self.release(run_sha) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.remote(self.conda_tag), run_sha) + self.assertEqual(self.remote('refs/heads/main'), run_sha, 'main was touched') + self.assertIn('is already tagged at +1 +2;', result.stdout) + + def test_a_tip_already_at_build_zero_is_left_alone(self) -> None: + run_sha = self.commit(self.VERSION, '2') + self.push(run_sha, 'refs/heads/main') + self.released(run_sha) + tip = self.commit(self.VERSION, '0') + self.push(tip, 'refs/heads/main') + self.checkout(run_sha) + + result = self.release(run_sha) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.remote('refs/heads/main'), tip) + + def test_a_tip_on_a_later_version_is_left_alone(self) -> None: + run_sha = self.commit(self.VERSION, '2') + self.push(run_sha, 'refs/heads/main') + self.released(run_sha) + self.commit('1.5.0b10', '0') + tip = self.commit('1.5.0b10', '2') + self.push(tip, 'refs/heads/main') + self.checkout(run_sha) + + result = self.release(run_sha) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.remote('refs/heads/main'), tip) + self.assertIn("has moved on to __version__ '1.5.0b10'", result.stdout) + + def test_a_commit_off_main_never_writes_to_main(self) -> None: + base = self.commit('1.5.0b8', '2') + self.push(base, 'refs/heads/main') + run_sha = self.commit(self.VERSION, '2') + self.push(run_sha, 'refs/heads/side') + self.released(run_sha) + self.checkout(run_sha) + + result = self.release(run_sha) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.remote(self.conda_tag), run_sha) + self.assertEqual(self.remote('refs/heads/main'), base) + self.assertIn('is not on main', result.stdout) + + def test_a_push_lost_to_a_concurrent_one_is_retried_on_top_of_it(self) -> None: + """No force: the rejected reset is rebuilt on the new tip, which survives.""" + run_sha = self.commit(self.VERSION, '2') + self.push(run_sha, 'refs/heads/main') + self.released(run_sha) + racer = self.commit(self.VERSION, '2', 'pushed while the reset was in flight') + self.checkout(run_sha) + + shim = self.tmp / 'bin' + shim.mkdir() + (shim / 'git').write_text( + '#!/bin/sh\n' + 'case "$1 $*" in\n' + ' "push "*refs/heads/main*)\n' + ' if [ ! -e "$RACE_MARKER" ]; then\n' + ' : > "$RACE_MARKER"\n' + ' "$REAL_GIT" -C "$RACE_FROM" push -q origin "$RACE_SHA:refs/heads/main" || exit 99\n' + ' fi ;;\n' + 'esac\n' + 'exec "$REAL_GIT" "$@"\n' + ) + (shim / 'git').chmod(0o755) + self.env.update(REAL_GIT=str(GIT), RACE_FROM=str(self.seed), RACE_SHA=racer, + RACE_MARKER=str(self.tmp / 'raced')) + + result = self.release(run_sha, path=str(shim)) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertTrue((self.tmp / 'raced').exists(), 'the race never happened') + self.assertIn('push to main rejected on attempt 1', result.stdout) + self.assertEqual(self.remote('refs/heads/main~1'), racer) + self.assertEqual(self.git('-C', str(self.origin), 'show', 'main:conda/build'), '0') + self.assertEqual(self.remote(self.conda_tag), run_sha) + + def test_a_rerun_keeps_a_conda_tag_already_on_the_run_commit(self) -> None: + run_sha = self.commit(self.VERSION, '0') + self.push(run_sha, 'refs/heads/main') + self.released(run_sha) + self.push(run_sha, self.conda_tag) + self.checkout(run_sha) + + result = self.release(run_sha) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn('already names', result.stdout) + + def test_a_conda_tag_on_another_commit_is_never_moved(self) -> None: + other = self.commit('1.5.0b8', '0') + run_sha = self.commit(self.VERSION, '0') + self.push(run_sha, 'refs/heads/main') + self.released(run_sha) + self.push(other, self.conda_tag) + self.checkout(run_sha) + + result = self.release(run_sha) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn('::error title=conda-1.5.0b9+0 is not this run', result.stdout) + self.assertEqual(self.remote(self.conda_tag), other) + + def test_a_release_tag_on_another_commit_stops_the_conda_tag(self) -> None: + """What a later run sees after a release tagged elsewhere -- 1.5.0b9 today.""" + run_sha = self.commit(self.VERSION, '0') + tip = self.commit(self.VERSION, '3') + self.push(tip, 'refs/heads/main') + self.released(tip) + self.checkout(run_sha) + + result = self.release(run_sha) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn(f'::error title=v{self.VERSION} is not this run', result.stdout) + self.assertIsNone(self.remote(self.conda_tag)) + + +class TestTheTagCheckScript(ScratchRepository): + """The check ``github``, ``pypi`` and ``conda`` share, run against real tags.""" + + TAGS = 'v1.5.0b9 conda-1.5.0b9+0' + + def check(self, run_sha: 'str') -> 'subprocess.CompletedProcess[str]': + return self.run_steps('conda', (CHECK_STEP,), {'PCAPKIT_TAGS': self.TAGS, 'PCAPKIT_RUN_SHA': run_sha}) + + def setUp(self) -> None: + super().setUp() + self.run_sha = self.commit(self.VERSION, '0') + self.later = self.commit(self.VERSION, '3') + self.push(self.later, 'refs/heads/main') + + def test_tags_on_the_run_commit_pass(self) -> None: + for tag in self.TAGS.split(): + self.push(self.run_sha, f'refs/tags/{tag}') + self.checkout(self.run_sha) + result = self.check(self.run_sha) + self.assertEqual(result.returncode, 0, result.stdout) + + def test_a_tag_on_another_commit_fails(self) -> None: + self.push(self.run_sha, 'refs/tags/v1.5.0b9') + self.push(self.later, 'refs/tags/conda-1.5.0b9+0') + self.checkout(self.run_sha) + result = self.check(self.run_sha) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn(f"::error title=conda-1.5.0b9+0 is not this run's commit (#1634)::conda-1.5.0b9+0 " + f"resolves to '{self.later}'", result.stdout) + + def test_a_missing_tag_fails(self) -> None: + self.push(self.run_sha, 'refs/tags/v1.5.0b9') + self.checkout(self.run_sha) + result = self.check(self.run_sha) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("resolves to 'no commit'", result.stdout) + + def test_a_tag_pushed_after_the_checkout_is_still_seen(self) -> None: + """``github``'s copy runs moments after creating the tag its checkout predates.""" + self.checkout(self.run_sha) + for tag in self.TAGS.split(): + self.push(self.run_sha, f'refs/tags/{tag}') + result = self.check(self.run_sha) + self.assertEqual(result.returncode, 0, result.stdout) + + +@unittest.skipIf(not BASH, 'needs bash on PATH') +class TestTheRefusalExplanation(WorkflowMixin, unittest.TestCase): + """``github``'s "Explain a refused release tag", run as the job runs it.""" + + #: The App's one-time setup, named whenever ``GITHUB_TOKEN`` was refused. + SETUP = ('Contents and Workflows read and write, installed on this repository only', + 'RELEASE_APP_CLIENT_ID', 'RELEASE_APP_BOT_ID', 'github-release environment secret RELEASE_APP_PRIVATE_KEY') + + def explain(self, outcome: 'str') -> 'str': + step = self.step('github', EXPLAIN_STEP) + env = {'PCAPKIT_VERSION': '1.5.0b9', 'PCAPKIT_RUN_SHA': 'b6b95bc8e', 'PCAPKIT_APP_TOKEN_OUTCOME': outcome} + self.assertEqual(sorted(env), sorted(step['env'])) + tmp = pathlib.Path(tempfile.mkdtemp(prefix='pcapkit-1634-explain-')) + self.addCleanup(shutil.rmtree, tmp, True) + (tmp / 'step.sh').write_text(step['run']) + result = subprocess.run([BASH, '-e', str(tmp / 'step.sh')], env=dict(env, PATH=os.environ.get('PATH', '')), + capture_output=True, text=True, check=False, timeout=scale_timeout(30)) + self.assertEqual(result.returncode, 0, result.stderr) + lines = result.stdout.splitlines() + self.assertEqual(len(lines), 1, lines) + self.assertTrue(lines[0].startswith('::error title=Could not create v1.5.0b9 at b6b95bc8e (#1634)::')) + return lines[0] + + def test_github_token_refused_names_the_apps_setup(self) -> None: + """``skipped``: ``vars.RELEASE_APP_CLIENT_ID`` was unset.""" + message = self.explain('skipped') + self.assertIn('Create Release spent GITHUB_TOKEN', message) + for part in self.SETUP: + with self.subTest(part=part): + self.assertIn(part, message) + self.assertIn('re-run this job', message) + + def test_the_apps_token_refused_names_its_permissions(self) -> None: + message = self.explain('success') + self.assertIn("Create Release spent the release App's token", message) + self.assertIn('Contents and Workflows read and write and is installed on this repository', message) + self.assertNotIn('RELEASE_APP_PRIVATE_KEY', message) + + +#: A stand-in for ``gh``. ``$GH_ASSETS`` holds the release's assets as +#: ``namestate`` lines; absent, there is no release, and ``release view`` +#: fails as the real one does ("release not found", exit 1, measured with gh +#: 2.102.0). It answers the three ``--json``/``--jq`` pairs the workflows ask, +#: the bare-name one being the script's before ``state`` was read, and +#: ``isDraft`` from ``$GH_DRAFT``. ``release upload`` appends the file as +#: ``uploaded`` and, like the real one, refuses a name already there unless +#: ``--clobber`` deletes it first. ``$GH_FAIL`` fails any call. Anything else +#: exits 96-99. +FAKE_GH = r'''#!/bin/sh +printf '%s\n' "$*" >> "$GH_LOG" +[ -z "${GH_FAIL:-}" ] || { echo "$GH_FAIL" >&2; exit 1; } +case "$1 $2" in + 'release view') + [ "$3 $4 $5" = "$PCAPKIT_RELEASE_TAG --repo $GITHUB_REPOSITORY" ] || exit 97 + [ -f "$GH_ASSETS" ] || { echo 'release not found' >&2; exit 1; } + shift 5 + case "$*" in + '--json assets --jq .assets[] | [.name, .state] | @tsv') cat "$GH_ASSETS" ;; + '--json assets --jq .assets[].name') cut -f 1 "$GH_ASSETS" ;; + '--json isDraft --jq .isDraft') echo "${GH_DRAFT:-false}" ;; + *) exit 96 ;; + esac ;; + 'release upload') + [ "$3" = "$PCAPKIT_RELEASE_TAG" ] && [ "$5 $6" = "--repo $GITHUB_REPOSITORY" ] || exit 98 + name="$(basename "$4")" + if [ "$#" -eq 7 ] && [ "$7" = --clobber ]; then + awk -F '\t' -v name="$name" '$1 != name' "$GH_ASSETS" > "$GH_ASSETS.new" + mv "$GH_ASSETS.new" "$GH_ASSETS" + elif [ "$#" -ne 6 ]; then + exit 98 + elif cut -f 1 "$GH_ASSETS" | grep -Fqx -e "$name"; then + echo "asset under the same name already exists: [$name]" >&2 + exit 1 + fi + printf '%s\tuploaded\n' "$name" >> "$GH_ASSETS" ;; + *) exit 99 ;; +esac +''' + +#: The ``--jq`` the upload script reads the release's assets with. +ASSETS_JQ = '.assets[] | [.name, .state] | @tsv' + + +@unittest.skipIf(not BASH, 'needs bash on PATH') +class TestTheUploadScript(WorkflowMixin, unittest.TestCase): + """``pypi`` and ``conda``'s upload, run as ``shell: bash`` runs it + (``bash --noprofile --norc -eo pipefail``) against :data:`FAKE_GH`.""" + + TAG = 'v1.5.0b9' + WHEEL = 'pypcapkit-1.5.0b9-cp311-none-any.whl' + SDIST = 'pypcapkit-1.5.0b9.tar.gz' + CONDA = 'pypcapkit-1.5.0b9-py311_0.tar.bz2' + + def setUp(self) -> None: + self.tmp = pathlib.Path(tempfile.mkdtemp(prefix='pcapkit-1640-')) + self.addCleanup(shutil.rmtree, self.tmp, True) + (self.tmp / 'bin').mkdir() + (self.tmp / 'bin' / 'gh').write_text(FAKE_GH) + (self.tmp / 'bin' / 'gh').chmod(0o755) + self.work = self.tmp / 'work' + self.work.mkdir() + self.assets = self.tmp / 'assets' + self.log = self.tmp / 'log' + + def build(self, *names: 'str', folder: 'str' = 'dist') -> 'list[str]': + (self.work / folder).mkdir(exist_ok=True) + for name in names: + (self.work / folder / name).write_text(name) + return [str(self.work / folder / name) for name in names] + + def published(self, *names: 'str', state: 'str' = 'uploaded') -> None: + """The release's assets, each in ``state``, the only one a finished upload has.""" + self.assets.write_text(''.join(f'{name}\t{state}\n' for name in names)) + + def on_release(self) -> 'list[str]': + return self.assets.read_text().splitlines() + + def upload(self, job: 'str', assets: 'Optional[str]' = None) -> 'subprocess.CompletedProcess[str]': + """Run ``job``'s copy with its own ``PCAPKIT_ASSETS`` unless ``assets`` replaces an expression.""" + step = self.step(job, UPLOAD_STEP) + env = {'GH_TOKEN': 'not-a-token', 'PCAPKIT_RELEASE_TAG': self.TAG, + 'PCAPKIT_ASSETS': step['env']['PCAPKIT_ASSETS'] if assets is None else assets} + self.assertEqual(sorted(env), sorted(step['env'])) + script = self.tmp / 'step.sh' + script.write_text(step['run']) + env.update({ + 'PATH': str(self.tmp / 'bin') + os.pathsep + os.environ.get('PATH', ''), 'HOME': str(self.tmp), + 'LC_ALL': 'C', 'GITHUB_REPOSITORY': 'JarryShaw/PyPCAPKit', + 'GH_ASSETS': str(self.assets), 'GH_LOG': str(self.log), + }) + return subprocess.run([BASH, '--noprofile', '--norc', '-eo', 'pipefail', str(script)], + cwd=self.work, env=env, capture_output=True, text=True, check=False, + timeout=scale_timeout(60)) + + def calls(self) -> 'list[str]': + return self.log.read_text().splitlines() if self.log.exists() else [] + + def expected_calls(self, *paths: 'str', clobber: 'bool' = False) -> 'list[str]': + view = f'release view {self.TAG} --repo JarryShaw/PyPCAPKit --json assets --jq {ASSETS_JQ}' + flag = ' --clobber' if clobber else '' + return [view] + [f'release upload {self.TAG} {path} --repo JarryShaw/PyPCAPKit{flag}' for path in paths] + + def test_a_published_conda_package_is_left_alone(self) -> None: + """#1640: run 38335468075 deleted and re-uploaded this file, rebuilt against live PyPI.""" + paths = self.build(self.CONDA, folder='linux-64') + self.published(self.WHEEL, self.CONDA) + result = self.upload('conda', '\n'.join(paths)) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.calls(), self.expected_calls()) + self.assertIn(f'::notice title={self.CONDA} already on {self.TAG}::', result.stdout) + self.assertEqual(self.on_release(), [f'{self.WHEEL}\tuploaded', f'{self.CONDA}\tuploaded']) + + def test_an_asset_left_by_a_failed_upload_is_uploaded_again(self) -> None: + """Only ``uploaded`` is a published file. + + Upload a release asset's REST docs: "When an upstream failure occurs, + you will receive a 502 Bad Gateway status. This may leave an empty + asset with a state of starter. It can be safely deleted." The response + schema's ``state`` enum is ``uploaded`` and ``open``, so both of the + others are tried. Kept, that asset is empty under the published name. + """ + for state in ('starter', 'open'): + with self.subTest(state=state): + self.log.unlink(missing_ok=True) + paths = self.build(self.CONDA, folder='linux-64') + self.published(self.CONDA, state=state) + result = self.upload('conda', '\n'.join(paths)) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.calls(), self.expected_calls(*paths, clobber=True)) + self.assertIn(f"::warning title={self.CONDA} uploaded again::{self.TAG} holds it in " + f"state '{state}', not 'uploaded'", result.stdout) + self.assertEqual(self.on_release(), [f'{self.CONDA}\tuploaded']) + + def test_only_the_files_the_release_lacks_go_up(self) -> None: + """``pypi``'s own ``dist/*``: the 3.14 leg's sdist, after its wheel landed.""" + self.build(self.WHEEL, self.SDIST) + self.published(self.WHEEL) + result = self.upload('pypi') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.calls(), self.expected_calls(f'dist/{self.SDIST}')) + self.assertEqual(self.on_release(), [f'{self.WHEEL}\tuploaded', f'{self.SDIST}\tuploaded']) + + def test_a_new_release_gets_every_file_in_order(self) -> None: + paths = self.build(self.CONDA, 'pypcapkit-1.5.0b9-py312_0.tar.bz2', folder='linux-64') + self.published() + result = self.upload('conda', '\n'.join(paths) + '\n') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.calls(), self.expected_calls(*paths)) + + def test_a_pattern_matching_nothing_fails(self) -> None: + """A change from the action, which only warned: ``fail_on_unmatched_files`` + is unset here, so 3.0.2's ``src/run.ts:15-20`` logs "🤔 Pattern '…' does + not match any files." and carries on.""" + self.published() + result = self.upload('pypi') + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("::error title=Nothing to upload::'dist/*' matches no file", result.stdout) + self.assertEqual(self.calls(), self.expected_calls()) + + def test_a_missing_release_fails_before_any_upload(self) -> None: + """A change from the action, which made one: with no release for the tag, + 3.0.2 calls ``createRelease`` (``src/github.ts:617-628``) with no notes + and ``target_commitish`` unset (``:1026``, ``src/util.ts:133``), which + Create a release defaults to "the repository's default branch". Making + the release is ``github``'s job, at the run's commit.""" + self.build(self.WHEEL) + result = self.upload('pypi') + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('release not found', result.stderr) + self.assertEqual(self.calls(), self.expected_calls()) + + +#: ``conda-dist``'s release check, the step before the action it gates. +RELEASE_CHECK_STEP = 'Check the GitHub release exists' +CONDA_RELEASE_TAG = 'v${{ needs.conda-update.outputs.PCAPKIT_VERSION }}' + +#: The action's ``commit``: the default branch on :file:`cron-conda.yml`'s triggers. +DEFAULT_BRANCH = '${{ github.ref_name }}' + + +class CondaDistMixin(WorkflowMixin): + """``conda-dist`` of :file:`cron-conda.yml`, and whether its GitHub upload runs.""" + + workflow = CONDA_WORKFLOW + + def upload(self) -> 'dict[str, Any]': + return self.step('conda-dist', UPLOAD_STEP) + + def runs(self, outputs: 'dict[str, str]', runner: 'str' = 'ubuntu-latest') -> 'bool': + """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}}, + }) + + +class TestCondaUpdateNeverMakesTheRelease(CondaDistMixin, unittest.TestCase): + """:file:`cron-conda.yml`'s GitHub upload only ever updates a published release. + + ``conda-dist`` attaches its packages with ``ncipollo/release-action@v1.21.0`` + and ``allowUpdates: true``, kept so #1632's ``test_cron_conda_really_does_publish`` + still finds it. That action's ``createOrUpdateRelease`` (``src/Action.ts:53-70``) + reads the release by tag; on a 404 it updates a draft of that tag or else + calls ``createRelease`` (``:72-92``) with ``target_commitish: commit`` + (``src/Releases.ts:76-99``), and its update sends ``draft: false`` unless + ``omitDraftDuringUpdate`` is set (``src/Inputs.ts:210-213``), publishing the + draft. Either way a bump made while Create Release waits on its approval -- + 1.5.0b9 waited three days, and cron-conda bumped three times meanwhile -- + would put ``v`` on ``main``'s tip, and Create Release's tag check + would then fail. On an existing release it calls Update a release with + ``target_commitish: commit`` (``src/Action.ts:107-120``, + ``src/Releases.ts:150-174``), and GitHub's REST docs check "the new value of + target_commitish if you are changing it, otherwise the existing target" + against the workflow-file rule, which since #1634 is the run's commit. + """ + + def test_the_action_is_still_the_upload(self) -> None: + step = self.upload() + self.assertEqual(step.get('uses'), 'ncipollo/release-action@v1.21.0') + self.assertEqual(step['with'].get('allowUpdates'), 'true') + self.assertEqual(step['with'].get('tag'), CONDA_RELEASE_TAG) + + def test_no_published_release_means_no_upload(self) -> None: + """The action runs on ``ubuntu-latest`` only, and only once the check says ``true``.""" + self.assertFalse(self.runs({}), 'the action runs without the release check') + self.assertFalse(self.runs({'published': 'false'}), + 'the action runs with no published release, and would make it') + self.assertFalse(self.runs({'published': 'true'}, runner='macos-latest')) + self.assertTrue(self.runs({'published': 'true'})) + + def test_the_check_reads_the_release_the_action_updates(self) -> None: + check = self.step('conda-dist', RELEASE_CHECK_STEP) + 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'), "${{ 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}) + self.assertNotIn('${{', check['run']) + + def test_the_anaconda_upload_is_not_gated_on_it(self) -> None: + self.assertNotIn('if', self.step('conda-dist', 'Upload conda packages to Anaconda')) + + def test_an_update_targets_the_default_branch(self) -> None: + """``github.ref_name`` is the default branch on this workflow's triggers. + + "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. + """ + self.assertEqual(self.upload()['with'].get('commit'), DEFAULT_BRANCH) + lines = CONDA_WORKFLOW.read_text(encoding='utf-8').splitlines() + start = lines.index('on:') + 1 + 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']) + + +#: A ``gh`` failing as it does without a token, for the check's other branch. +GH_AUTH_ERROR = 'HTTP 401: Bad credentials (https://api.github.com/graphql)' + + +@unittest.skipIf(not BASH, 'needs bash on PATH') +class TestTheReleaseCheckScript(CondaDistMixin, unittest.TestCase): + """``conda-dist``'s check, run as ``shell: bash`` runs it against :data:`FAKE_GH`, + and the action's ``if:`` evaluated on what it outputs.""" + + TAG = 'v1.5.0b9' + + def setUp(self) -> None: + self.tmp = pathlib.Path(tempfile.mkdtemp(prefix='pcapkit-1634-conda-')) + self.addCleanup(shutil.rmtree, self.tmp, True) + (self.tmp / 'bin').mkdir() + (self.tmp / 'bin' / 'gh').write_text(FAKE_GH) + (self.tmp / 'bin' / 'gh').chmod(0o755) + self.assets = self.tmp / 'assets' + self.output = self.tmp / 'output' + self.log = self.tmp / 'log' + + def check(self, **env: 'str') -> 'tuple[subprocess.CompletedProcess[str], dict[str, str]]': + step = self.step('conda-dist', RELEASE_CHECK_STEP) + script = self.tmp / 'step.sh' + script.write_text(step['run']) + env.update({ + 'GH_TOKEN': 'not-a-token', 'PCAPKIT_RELEASE_TAG': self.TAG, + 'PATH': str(self.tmp / 'bin') + os.pathsep + os.environ.get('PATH', ''), 'HOME': str(self.tmp), + 'TMPDIR': str(self.tmp), 'LC_ALL': 'C', 'GITHUB_REPOSITORY': 'JarryShaw/PyPCAPKit', + 'GITHUB_OUTPUT': str(self.output), 'GH_ASSETS': str(self.assets), 'GH_LOG': str(self.log), + }) + result = subprocess.run([BASH, '--noprofile', '--norc', '-eo', 'pipefail', str(script)], + cwd=self.tmp, env=env, capture_output=True, text=True, check=False, + timeout=scale_timeout(60)) + lines = self.output.read_text().splitlines() if self.output.exists() else [] + return result, {key: value for key, _, value in (line.partition('=') for line in lines)} + + def assert_viewed_once(self) -> None: + self.assertEqual(self.log.read_text().splitlines(), + [f'release view {self.TAG} --repo JarryShaw/PyPCAPKit --json isDraft --jq .isDraft']) + + def test_no_release_skips_the_github_upload(self) -> None: + result, outputs = self.check() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(outputs, {'published': 'false'}) + self.assertFalse(self.runs(outputs), 'the action would run, and create the release at main') + self.assertIn(f'::notice title=No GitHub upload for {self.TAG}::{self.TAG} does not exist', result.stdout) + self.assert_viewed_once() + + def test_a_draft_skips_the_github_upload(self) -> None: + """What a refused final release leaves behind, until it is deleted.""" + self.assets.write_text('') + result, outputs = self.check(GH_DRAFT='true') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(outputs, {'published': 'false'}) + self.assertFalse(self.runs(outputs), 'the action would run, and publish the draft') + self.assertIn(f'{self.TAG} is a draft, which the upload would publish', result.stdout) + + def test_a_published_release_is_uploaded_to(self) -> None: + """With ``commit`` the default branch, so Update a release resolves ``main``.""" + self.assets.write_text('') + result, outputs = self.check() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(outputs, {'published': 'true'}) + self.assertNotIn('::notice', result.stdout) + self.assertTrue(self.runs(outputs)) + self.assertEqual(self.upload()['with'].get('commit'), DEFAULT_BRANCH) + self.assert_viewed_once() + + def test_any_other_failure_fails_the_step(self) -> None: + """Not read as "no release": that would skip a GitHub upload that was owed.""" + result, outputs = self.check(GH_FAIL=GH_AUTH_ERROR) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertEqual(outputs, {}) + self.assertIn(GH_AUTH_ERROR, result.stderr) + self.assertIn(f'::error title=Could not read {self.TAG}::gh release view exited 1.', result.stdout) + + +class TestYAMLAgreesWithTheScanner(WorkflowMixin, unittest.TestCase): + """The hand-rolled scanner against :func:`yaml.safe_load`, where PyYAML is installed.""" + + def test_the_same_steps(self) -> None: + self.assert_the_same_steps(WORKFLOW, self.steps, every_job=True) + + def test_the_same_conda_dist_steps(self) -> None: + """The one job of :file:`cron-conda.yml` read here.""" + steps = workflow_steps(CONDA_WORKFLOW.read_text(encoding='utf-8')) + self.assert_the_same_steps(CONDA_WORKFLOW, {'conda-dist': steps['conda-dist']}) + + def assert_the_same_steps(self, workflow: 'pathlib.Path', steps: 'dict[str, list[dict[str, Any]]]', + every_job: 'bool' = False) -> None: + try: + import yaml # pylint: disable=import-outside-toplevel + except ImportError: + self.skipTest('PyYAML is not installed (the `test` extra)') + jobs = yaml.safe_load(workflow.read_text(encoding='utf-8'))['jobs'] + if every_job: + self.assertEqual(sorted(steps), sorted(jobs)) + for job in sorted(steps): + expected = jobs[job].get('steps', []) + scanned = steps[job] + with self.subTest(job=job): + self.assertEqual([label(step) for step in scanned], [label(step) for step in expected]) + for mine, theirs in zip(scanned, expected): + for key in ('uses', 'id', 'if', 'run'): + self.assertEqual(mine.get(key), theirs.get(key), f'{label(theirs)}: {key}') + for key in ('ref', 'target_commitish', 'commit', 'token', *MINT_WITH): + self.assertEqual(mine.get('with', {}).get(key), theirs.get('with', {}).get(key), + f'{label(theirs)}: with.{key}') + self.assertEqual(mine.get('env', {}), {k: str(v) for k, v in theirs.get('env', {}).items()}, + f'{label(theirs)}: env') + + +if __name__ == '__main__': + unittest.main()