Skip to content

fix(ci): a half-finished release leaves a v* tag that makes every retry skip silently #888

Description

@JarryShaw

Describe the bug

Every release job in create-release.yml shares one guard:

if: ${{ startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_TAG_EXISTS == 'false' }}

version_check (:89-130) is ungated and read-only — it runs mukunku/tag-exists-action@v1.7.0 against v<version> and emits PCAPKIT_TAG_EXISTS. The v* tag itself is created by softprops/action-gh-release at :199 (tag_name at :216) inside the github job. So once the tag exists but the publish did not complete, the guard's behaviour splits by trigger:

trigger github.ref_name startsWith(…, 'v') with the tag present
push: tags: ['v*'] (:2-5) v1.5.0 true short-circuits, all jobs run
workflow_run ← "Vendor Update" (:6-9) main — the run evaluates the default-branch tip, per the comment at :15-19 false every release job skips

Reproduction

Not run against production — derived from the workflow at ae8a9467d. The reachable sequence: a workflow_run-triggered run tags and publishes the GitHub Release, then pypi or conda fails (or, today, its separate approval is rejected — see #887). The v<version> tag now exists with no PyPI/Anaconda upload. The next workflow_run run reads PCAPKIT_TAG_EXISTS=true, ref_name=main, and every release job is skipped. The run is green, because skipped is not failed, so nothing surfaces.

Expected behavior

A retry after a partial release either completes the missing publishes or fails loudly. "Tag exists" is being used as a proxy for "this version was already released", and those diverge exactly when a release half-happens — which is the case the guard most needs to handle.

Shapes worth considering, in rough order of cost:

  • Gate each publish on its own evidence rather than on the tag — e.g. skip the PyPI upload only when that version is already on PyPI, and Anaconda only when it is already on Anaconda. Each job then self-heals on retry.
  • Keep the tag check but make a skip-because-already-released outcome explicit — a short job that fails, or at least annotates the run, when the tag exists and the run was not triggered by a v* push. Cheapest way to stop a green no-op.
  • Recovery, and what is not recovery. Pushing a fresh v<version> tag by hand is a supported way to start a release and always was — see ci(release): one approval for the whole release, not four separate environment gates #887, where that was settled explicitly. What does not work is using a tag push to retry a stranded one: in that scenario the tag already exists, and a push trigger fires on ref creation or update, so re-pushing an identical ref produces no event. Making it fire would mean deleting and re-pushing, which touches a ref the release automation owns and lands the retry on the startsWith path with the tag-exists check bypassed, against a version that may already be on PyPI. So there is no sanctioned recovery for an already-stranded tag — not because hand-tagging is forbidden, but because there is no fresh tag left to push.

System information

Workflow file only; no library version involved. Read at origin/main = ae8a9467d.

Additional context

Found while answering whether the manual approvals gate tagging (they do — the tag is created inside the github-release-gated job). #887 carries the separate ask to collapse four approvals into one, which makes this rarer but does not remove it: a mid-run failure after tagging still lands here.

One unrelated oddity noticed in the same trace, recorded so it is not lost: the tag job runs git tag -d v<version> || true at :275 before creating conda-<version>+0. It is local-only so it deletes nothing on the remote, but it reads like "delete the release tag" and cost a second look.

Activity

  1. added
    bugIssues reporting a defect (set by the bug report template; a default, not an assessment)
    ciPull requests that change CI or workflow configuration (ci: subject prefix)
    on Sep 28, 2026
  2. JarryShaw commented on Sep 28, 2026

    @JarryShaw
    OwnerAuthor

    Checkable blocker: #887, and my recommendation for when it clears.

    gh issue view 887 -R JarryShaw/PyPCAPKit --json state

    #887 changes this defect's failure surface, so fixing it first would mean designing against a shape that is about to move. Today the v* tag can be left behind two ways: a rejected approval on one of the four gates after github-release was approved, or a failed job after tagging. Collapsing to one approval removes the first entirely — reject and nothing is tagged at all — leaving only the mid-run failure. A fix that handles both is more machinery than a fix that handles one.

    Recommendation, for when #887 lands: the first of the three shapes — gate each publish on its own evidence rather than on the tag. Skip the PyPI upload only when that version is already on PyPI, and Anaconda only when it is already on Anaconda. That makes each job self-healing on retry, which is the property actually wanted here: a half-finished release completes on the next run instead of being skipped or needing a hand-pushed tag. It also removes the coupling that makes PCAPKIT_TAG_EXISTS do work it was never suited for — the tag answers "was this tagged", not "was this published", and those are the same only while nothing fails.

    The second shape (make the skip explicit, failing or annotating when the tag exists and the trigger was not a v* push) is worth doing regardless, and is a few lines. It does not fix the retry, but it converts a green no-op into something visible, which is the part that actually cost time here. If only one thing gets done, it should be this.

    Not recommending the third beyond documentation: pushing the v<version> tag by hand works because it takes the startsWith(github.ref_name, 'v') branch, but it is a workaround that depends on someone knowing this issue exists.

    Labelled blocked on #887. Nothing dispatched.

  3. added
    blockedDeferred pending another issue or decision; see the last comment for what unblocks it
    on Sep 28, 2026
  4. JarryShaw commented on Sep 29, 2026

    @JarryShaw
    OwnerAuthor

    Revising my own recommendation above: the manual-tag recovery is withdrawn, and this issue is now the whole of the remaining gap.

    The owner's ruling on #887 is "Only bump pcapkit.__version__ string. Never manual tag or touch anything." My note above recommended pushing the v<version> tag by hand to recover from a half-finished release. That is exactly what the ruling forbids, and the ruling is right on the mechanics, not just on hygiene: a hand-pushed tag takes the push: tags: ['v*'] trigger, where startsWith(github.ref_name, 'v') short-circuits the guard, so the workaround works precisely because it disables the check that would otherwise stop a double-publish. Withdrawn.

    What that leaves. #887 has landed its settings half — one approval on github-release, reviewers cleared from conda-tag, pypi and anaconda — so the rejected-gate route into a stranded tag is gone: reject and nothing is tagged at all. What survives is the route this issue is actually about: the github job succeeds, the tag exists, and a later job fails. Then on the workflow_run path (github.ref_name = main, so the startsWith short-circuit does not apply) every release job is skipped and the run reports green, with no sanctioned way to retry.

    So the blocker is cleared and the shape has narrowed. My recommendation stands and firms up:

    Gate each publish on its own evidence, not on the tag. Skip the PyPI upload only when that version is already on PyPI; skip Anaconda only when it is already there. Each job then self-heals on re-run, which is the only recovery compatible with "never manual tag" — you re-run the workflow and the jobs that did not finish simply finish. The tag answers "was this tagged", never "was this published", and those diverge exactly when something fails mid-release.

    And the cheap half is now more valuable than before, not less: make the skip explicit — fail, or at minimum annotate, when the tag exists and the trigger was not a v* push. Without it the operator's only signal that a release did not happen is a green run that did nothing, and with manual tagging correctly off the table there is no longer an informal escape hatch to notice the problem by.

    Removing blocked; #887's settings change has landed and its remaining work (the needs: fix and the release doc) does not gate this. Not dispatching yet — the doc being written for #887 has to describe this failure mode and its absence of a recovery, and I would rather the two land in an order where the doc does not immediately go stale. Ready to dispatch on your word, or once #887's PR is in.

  5. removed
    blockedDeferred pending another issue or decision; see the last comment for what unblocks it
    on Sep 29, 2026
  6. JarryShaw commented on Sep 29, 2026

    @JarryShaw
    OwnerAuthor

    Checkable blocker: #893, which is in review now and documents this gap as it currently stands.

    gh pr view 893 -R JarryShaw/PyPCAPKit --json state,mergedAt

    #893 adds docs/source/releasing.rst, whose recovery section says in terms that re-running is the sanctioned route, that it can silently no-op on the workflow_run path, and that no sanctioned manual recovery exists for an already-stranded tag — citing this issue as the reason. Changing that behaviour while #893 is under cross-review would invalidate the review of prose describing it, and land a document that is wrong on arrival.

    So this is sequenced, not idle: once #893 merges, this gets a worker and the doc gets the corresponding paragraph updated in the same change. blocked applied with that written down, since a board entry with no label reads as nobody's problem.

    Recommendation unchanged from above — gate each publish on its own evidence (already on PyPI / already on Anaconda) so a plain re-run self-heals, plus the cheap half of making the skip explicit rather than a green no-op.

  7. added
    blockedDeferred pending another issue or decision; see the last comment for what unblocks it
    on Sep 29, 2026
  8. JarryShaw commented on Sep 29, 2026

    @JarryShaw
    OwnerAuthor

    Read #887 again, we had agreed that manual tagging should still be allowed and supported.

  9. JarryShaw commented on Sep 29, 2026

    @JarryShaw
    OwnerAuthor

    You are right, and I over-corrected — twice in the same direction, which is worth naming.

    Read #887 again, we had agreed that manual tagging should still be allowed and supported.

    Agreed there and still true. My first body edit struck the recovery bullet through as though hand-tagging were the problem; it is not. Body now says what actually holds: pushing a fresh v<version> tag is a supported way to start a release, and the thing that does not work is using a tag push to retry a stranded one — because in that case the tag already exists, so there is no new ref event to fire, and forcing one means deleting a ref the automation owns.

    So the sentence is "there is no sanctioned recovery for an already-stranded tag, not because hand-tagging is forbidden but because there is no fresh tag left to push". That is a statement about the situation, not a prohibition on the tool — which is the distinction I kept collapsing.

    The cross-review of #893 found the same confusion had reached the release doc from a different angle, so the corrected framing is going into releasing.rst in that PR's current round too.

  10. JarryShaw commented on Sep 29, 2026

    @JarryShaw
    OwnerAuthor

    Unblocked — #893 merged as 486c2a269 at 2026-09-29T02:07:23Z, so the blocker recorded above is discharged. blocked removed.

    That leaves the one thing #893 could not settle: which of the two shapes in the body to build. They are not alternatives so much as different jobs, and my recommendation is both, in this order:

    1. Gate each publish on its own evidence — skip the PyPI upload only when that version is already on PyPI, and Anaconda only when it is already on Anaconda. This is the actual fix: it makes a retry after a partial release self-heal, and it stops using "tag exists" as a proxy for "this version shipped", which is precisely the inference that breaks when a release half-happens.
    2. Make a skip-because-already-released outcome explicit — annotate or fail rather than passing green. On its own this only stops the silent no-op and leaves you with no retry path, so it is the safety net, not the fix. Worth having because it covers the case where (1)'s evidence check is itself wrong.

    The ask: both, or (2) alone for now? I am recommending both. (1) is the larger change and touches the jobs that publish to PyPI and Anaconda, so it is also the one where I would rather have your word than my judgement.

    Marking needs: decision for that choice only. Meanwhile I am dispatching a read-only investigation to establish the part that is the same either way — whether a version's presence on PyPI and on Anaconda can be checked reliably from inside the workflow, and what the existing jobs already have in hand. Nothing will be committed or pushed from it; it just means the implementation is ready the moment you rule.

    Also still open from the body, unrelated and not part of this: tag runs git tag -d v<version> || true at :275, which is local-only and deletes nothing on the remote, but reads like "delete the release tag".

  11. added
    needs: decisionWaiting on the maintainer to decide — not blocked by other work
    and removed
    blockedDeferred pending another issue or decision; see the last comment for what unblocks it
    on Sep 29, 2026
  12. 3 remaining items

  13. added
    wipWork in flight - a covering PR is open or an agent is actively on it
    and removed
    needs: decisionWaiting on the maintainer to decide — not blocked by other work
    on Sep 29, 2026
  14. JarryShaw commented on Sep 29, 2026

    @JarryShaw
    OwnerAuthor

    Correction to this issue's own body, found by watching a live release: version_check is not ungated, and there is a second silent-skip path this issue does not describe.

    The body says "version_check is ungated and read-only". It is not. At current origin/main:

     96  unit-tests:
     97    name: Release test gate
     98    if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
    ...
    105  version_check:
    106    name: Check Version
    108    needs: [ unit-tests ]
    

    So when a workflow_run trigger arrives from a Vendor Update run that concluded anything other than success, unit-tests skips, version_check skips with it, and every downstream release job skips — without the shared PCAPKIT_TAG_EXISTS guard ever being evaluated.

    Observed, not theorised. Two Create Release runs today concluded skipped with Check Version itself skipped:

    run 36511610205  02:13  486c2a26   Check Version skipped, all 5 publish jobs skipped
    run 36510774776  02:03  5e7f91eb   same
    

    So I was wrong to read those as this issue's defect. They are the other path: the triggering Vendor Update had nothing to do, so the release correctly did nothing. The run still reports skipped rather than anything a human would notice, which is the same complaint — a green no-op — but a different mechanism, and conflating them would have produced a fix aimed at the wrong guard.

    This has a direct consequence for the fix now being implemented. Gating pypi's if: on an evidence output from version_check inherits version_check's own skip: if unit-tests skips, the output never exists and pypi skips regardless of what the evidence would have said. An evidence check that lives downstream of a skippable job cannot make a skip loud. Either the evidence job must not depend on unit-tests, or the "did this skip for a legitimate reason" annotation has to sit somewhere that always runs. I have sent this to the worker.

    Meanwhile the healthy path is working. The Vendor Update bot bumped to 1.5.0b6 at 02:29:58Z (59dc8c0aa, also regenerating the five pcapkit/const/reg/apptype/*.py files), and a release is in progress now on that sha — Release test gate / Gate (full suite) running, run 36512890404. v1.5.0b6 does not exist yet (git ls-remote --tags shows only v1.5.0b4 and v1.5.0b5), so PCAPKIT_TAG_EXISTS is false and the guard passes as designed. Nothing for me to do there; flagging it so you know a release is live.

    One incidental: v1.5.0b5 points at ae8a9467d, a docs-only commit of mine.

  15. JarryShaw commented on Sep 29, 2026

    @JarryShaw
    OwnerAuthor

    I'm thinking we might want to include somewhere in the documentation for workflow graphs of these CI/CD GHA workflows.

  16. JarryShaw commented on Sep 29, 2026

    @JarryShaw
    OwnerAuthor

    Good ask, and it is broader than this issue, so I have given it its own: #897.

    I'm thinking we might want to include somewhere in the documentation for workflow graphs of these CI/CD GHA workflows.

    There are eight workflows and the interesting structure is the edges between them, which no single file shows: deploy-pages.yml, create-release.yml and cron-conda.yml all fire on workflow_run from an upstream workflow, and create-release.yml additionally calls unit-tests.yml as a reusable workflow — a different relationship from triggering it. That distinction is exactly what bit this issue: the skip cascade I posted about above runs unit-tests → version_check → every publish job, and it is invisible from any one file.

    Scoped #897 to cover trigger kinds, the workflow_run edges, reusable-workflow calls, where the required status checks come from, and which jobs declare an environment: and so can pause for approval. docs/source/releasing.rst stays out of scope — it already documents the release path and is being edited under this issue right now; #897 will cross-reference it rather than duplicate it.

    Worker dispatched on #897. This issue keeps its own scope: the guard fix.

  17. removed
    wipWork in flight - a covering PR is open or an agent is actively on it
    on Sep 29, 2026
  18. added this to the 1.5 milestone on Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugIssues reporting a defect (set by the bug report template; a default, not an assessment)ciPull requests that change CI or workflow configuration (ci: subject prefix)

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions