Skip to content

fix(release): never attempt a PyPI upload from workflow_dispatch - #52

Merged
trsdn merged 2 commits into
mainfrom
fix-release-postjobs
Aug 24, 2026
Merged

trsdn merged 2 commits into
mainfrom
fix-release-postjobs

Conversation

@trsdn

@trsdn trsdn commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Follow-up to #48 / #50. Last remaining hardening: release.yml accepts workflow_dispatch, but nothing stopped a manual run from attempting a PyPI upload.

publish was gated only on is-prerelease, so a manual dispatch ran all the way to the upload and would always fail — PyPI rejects re-uploading an existing version.

Fix

startsWith(github.ref, 'refs/tags/') added to three jobs:

Job Condition
publish startsWith(github.ref, 'refs/tags/') && needs.validate-release.outputs.is-prerelease == 'false'
create-github-release always() && startsWith(github.ref, 'refs/tags/') && …
post-release-validation always() && startsWith(github.ref, 'refs/tags/')

post-release-validation needed it too: with a bare if: always() every dispatch would end in "GitHub release missing". A manual dispatch is now a clean dry run of build + quality gates.

This matches the guard already in place in trsdn/paperless-mcp and trsdn/obsidian-mcp.

pre-release.yml: tag guard deliberately NOT applied

That workflow is triggered only by workflow_dispatch and generates the rc/alpha/beta version itself, so a tag guard would disable it entirely. The TestPyPI duplicate risk there only materialises if the same version is generated twice — a separate question that should be decided on its own.

What was fixed there instead is the same least-privilege gap already closed in release.yml — id-token: write sat at workflow level:

# before (workflow level)      # after
permissions:                   permissions:
  contents: write                contents: read
  id-token: write              # test-pypi-upload:  { id-token: write }
  packages: write              # create-prerelease: { contents: write }

packages: write was unused and is removed.

Validation

  • actionlint on both workflows: 12 findings, identical to main (all pre-existing, in untouched lines).
  • All three if expressions and the rescoped permissions verified by parsing the YAML.

Note: this branch originally also carried the update-docs / post-release-validation / pip index versions fixes. Those landed via #50, so the branch was rebuilt on top of main and now contains only the change above.

trsdn and others added 2 commits August 25, 2026 00:30
`release.yml` accepts `workflow_dispatch`, but `publish` was only gated on
`is-prerelease`. A manual run therefore proceeded all the way to the upload and
would always fail, because PyPI rejects re-uploading an existing version.

- `publish`, `create-github-release` and `post-release-validation` are now
  additionally gated on `startsWith(github.ref, 'refs/tags/')`. A manual
  dispatch becomes a dry run of build + quality gates with no upload attempt and
  no "GitHub release missing" failure at the end.
- `pre-release.yml` is dispatch-only by design (it generates the rc/alpha/beta
  version itself), so a tag guard would disable it outright and is deliberately
  not applied. Its workflow-level `id-token: write` / `contents: write` /
  `packages: write` is rescoped instead: the default is now `contents: read`,
  `id-token: write` lives only in `test-pypi-upload` and `contents: write` only
  in `create-prerelease`. Unused `packages: write` removed.

Matches the guard already present in trsdn/paperless-mcp and trsdn/obsidian-mcp.
The post-publish job fixes previously in this branch landed via #50; this branch
was rebuilt on top of main to keep only the remaining change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

🔍 PR Quality Summary

CI Status

🔄 Workflows in progress...

Metrics

Metric Value Trend
📊 Coverage N/A -
🧪 Tests Test results unavailable -
⏱️ Performance No performance data -

Quality Checks

  • Format & Lint: Ruff formatting and linting
  • Type Safety: MyPy strict type checking
  • Security: Bandit, Safety, GitLeaks scanning
  • MCP Protocol: Tool schema validation
  • Documentation: Docstring coverage (80%+)

MCP Tools

  • convert_file - Convert individual files to Markdown
  • convert_directory - Batch convert directories
  • list_supported_formats - Query supported file types

🤖 Auto-generated by CI • Last updated: 2026-08-24 22:33 UTC

@trsdn

trsdn commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

Die Permissions-Härtung in pre-release.yml ist gut und sollte rein: id-token: write vom Workflow-Scope runter auf den TestPyPI-Upload-Job, contents: write nur dort, wo wirklich getaggt wird, und der Rest auf contents: read. Genau richtig.

Der Guard auf publish muss aber raus — das ist derselbe Punkt, den ich schon an #49 kommentiert hatte, und er ist hier unverändert wieder drin:

if: |
  startsWith(github.ref, 'refs/tags/')
  && needs.validate-release.outputs.is-prerelease == 'false'

Warum das den dokumentierten Re-Run-Pfad abschaltet

Der Kommentar begründet den Guard damit, ein workflow_dispatch sei „a dry run of build + quality gates". Das widerspricht dem, wofür der Dispatch in diesem Workflow ausgelegt ist:

workflow_dispatch:
  inputs:
    tag:
      description: 'Existing release tag to (re-)run the pipeline for, e.g. v1.2.3'
      required: true
      type: string

Der Input ist required, und jeder Job checkt konsequent ref: ${{ inputs.tag || github.ref }} aus (Zeilen 39, 91, 148, 191, 290, 488). Ein Dispatch ist hier also kein Dry-Run, sondern ausdrücklich der Weg, die Pipeline für einen bereits existierenden Tag noch einmal laufen zu lassen.

Bei workflow_dispatch ist github.ref aber refs/heads/main und nicht der Tag — die Bedingung ist damit immer false. Folge: publish wird still übersprungen. Kein Fehler, keine Meldung, der Lauf wird grün und veröffentlicht nichts. Das ist die unangenehmste Fehlerart, weil sie wie Erfolg aussieht.

Das ist kein theoretischer Fall

v2.0.0 hat drei Anläufe gebraucht (21:56, 22:08, 22:12), weil das mypy --strict-Gate unter Python 3.12 an einem PEP-695-type-Statement in den numpy-Stubs scheiterte. Genau in so einer Situation — Tag existiert, Pipeline ist mittendrin abgebrochen — ist der Dispatch-Re-Run das Mittel der Wahl. Mit diesem Guard bliebe nur, den Tag zu löschen und neu zu pushen.

Das Szenario im Kommentar ist bereits abgedeckt

Die Sorge vor einem doppelten Upload ist berechtigt, aber validate-release fängt das schon ab:

- name: Check if tag already exists in releases
  run: |
    if gh release view "$TAG" >/dev/null 2>&1; then
      echo "❌ Release for tag $TAG already exists"
      exit 1
    fi

Ein Re-Run für einen vollständig abgeschlossenen Release bricht damit früh und laut ab. Ein Re-Run für einen halb fehlgeschlagenen Release ist genau der Fall, den man will.

Vorschlag: den startsWith(github.ref, 'refs/tags/')-Teil ersatzlos streichen und if: needs.validate-release.outputs.is-prerelease == 'false' belassen. Analog für create-github-release und post-release-validation, falls dort dieselben Guards drinstecken. Die Permissions-Änderungen bleiben davon unberührt und können so bleiben.

Zur Einordnung, weil es leicht zu verwechseln ist

In trsdn/obsidian-mcp habe ich denselben Guard bewusst eingebaut (PR #11) — dort hat workflow_dispatch aber keinen Tag-Input, sodass ein Dispatch die Version von main ungefragt veröffentlicht hätte. Gleiche Zeile, gegenteilige Wirkung; entscheidend ist das Trigger-Design des jeweiligen Workflows.

@trsdn
trsdn merged commit 14d22ce into main Aug 24, 2026
5 checks passed
@trsdn
trsdn deleted the fix-release-postjobs branch August 24, 2026 22:42
trsdn added a commit that referenced this pull request Aug 24, 2026
…eline (#54)

PR #52 added `startsWith(github.ref, 'refs/tags/')` to publish,
create-github-release and post-release-validation. That guard is wrong
for this workflow: workflow_dispatch takes a *required* `tag` input and
every job checks it out via `inputs.tag || github.ref`, so a dispatch is
the documented way to re-run the pipeline for an existing tag.

On a dispatch `github.ref` is `refs/heads/main`, so the guard was always
false. The upload was skipped silently and the run still went green --
failure that looks like success. v2.0.0 needed three attempts, which is
exactly when that re-run path matters.

Duplicate uploads were never the risk this guard protected against:
validate-release already exits 1 via "Check if tag already exists in
releases".

The least-privilege permissions work from #52 is kept untouched.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant