Release #13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| # Triggered by CI completing on main. We do NOT trigger on push directly | |
| # so a release can never ship if the test matrix failed. | |
| # | |
| # Auto-bump model: every CI-success push to main increments the patch | |
| # version (0.1.0 → 0.1.1 → 0.1.2 → …). The bump itself lands as a | |
| # `chore: bump version to X.Y.Z` commit pushed back to main; that push | |
| # uses GITHUB_TOKEN, which by GitHub's rules does NOT trigger CI again, | |
| # so there's no infinite loop. As belt-and-suspenders we also skip the | |
| # bump if the previous commit was already an auto-bump. | |
| # | |
| # Escape hatch: a commit message containing `[skip release]` opts out | |
| # of bumping AND publishing for that push. | |
| # | |
| # CI (ci.yml) succeeds on main push | |
| # │ | |
| # ▼ workflow_run: completed + conclusion == success | |
| # bump-version → publish-pypi → create-draft-release | |
| # │ | |
| # ▼ | |
| # build-nuitka [linux | windows | macos] | |
| # │ | |
| # ▼ | |
| # publish-release (unmark draft) | |
| on: | |
| workflow_run: | |
| workflows: [CI] | |
| types: [completed] | |
| branches: [main] | |
| permissions: | |
| # contents:write is needed to push the bump commit, create tags + | |
| # releases, and upload release assets (the Nuitka binaries). | |
| contents: write | |
| concurrency: | |
| group: release-main | |
| cancel-in-progress: false | |
| jobs: | |
| bump-version: | |
| name: Auto-bump patch version | |
| # Only run when CI succeeded. workflow_run fires on every CI | |
| # completion (success, failure, cancelled), so we have to gate | |
| # this explicitly. | |
| if: github.event.workflow_run.conclusion == 'success' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.bump.outputs.version }} | |
| sha: ${{ steps.push.outputs.sha }} | |
| skipped: ${{ steps.gate.outputs.skip }} | |
| steps: | |
| - name: Checkout main | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: main | |
| # Need history to inspect the previous commit message for | |
| # loop detection. | |
| fetch-depth: 2 | |
| # persist-credentials lets the final `git push` reuse the | |
| # GITHUB_TOKEN this job was issued. | |
| persist-credentials: true | |
| - name: Decide whether to bump | |
| id: gate | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| MSG=$(git log -1 --pretty=%B) | |
| if echo "$MSG" | grep -q '\[skip release\]'; then | |
| echo "Commit carries [skip release]; not bumping." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| elif echo "$MSG" | grep -q '^chore: bump version to '; then | |
| # The previous push WAS an auto-bump. GitHub's rule about | |
| # GITHUB_TOKEN-pushes not triggering workflows should have | |
| # already broken any loop, but this is a safety net. | |
| echo "Previous commit was an auto-bump; not re-bumping." | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Configure git author | |
| if: steps.gate.outputs.skip == 'false' | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| - name: Bump patch version | |
| id: bump | |
| if: steps.gate.outputs.skip == 'false' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| OLD=$(grep -E '^version *= *"' pyproject.toml | head -1 \ | |
| | sed -E 's/.*"([^"]+)".*/\1/') | |
| if [ -z "$OLD" ]; then | |
| echo "::error::could not read current version from pyproject.toml" | |
| exit 1 | |
| fi | |
| # Split X.Y.Z and increment Z. If the version has more or | |
| # fewer components, fail loudly rather than guess. | |
| if ! [[ "$OLD" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then | |
| echo "::error::version $OLD is not a plain X.Y.Z — pre-release tags require manual bump" | |
| exit 1 | |
| fi | |
| X="${BASH_REMATCH[1]}" | |
| Y="${BASH_REMATCH[2]}" | |
| Z="${BASH_REMATCH[3]}" | |
| NEW="$X.$Y.$((Z+1))" | |
| sed -i "s|^version = \"$OLD\"|version = \"$NEW\"|" pyproject.toml | |
| echo "Bumped $OLD → $NEW" | |
| echo "version=$NEW" >> "$GITHUB_OUTPUT" | |
| - name: Commit + push bump | |
| id: push | |
| if: steps.gate.outputs.skip == 'false' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| git add pyproject.toml | |
| git commit -m "chore: bump version to ${{ steps.bump.outputs.version }}" | |
| git push origin HEAD:main | |
| # Capture the SHA of the bump commit so downstream jobs build | |
| # exactly that revision. | |
| echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | |
| publish-pypi: | |
| name: Build + publish to PyPI | |
| needs: bump-version | |
| if: needs.bump-version.outputs.skipped == 'false' | |
| runs-on: ubuntu-latest | |
| # No `environment:` block on purpose. Attaching one would make | |
| # GitHub categorise the publish as a Deployment and surface it | |
| # under the repo's "Deployments" sidebar widget. The artefact | |
| # of a successful run is a GitHub Release + a PyPI version — | |
| # both already have first-class UI in their respective places — | |
| # so the Deployment view is redundant noise on the repo home. | |
| steps: | |
| - name: Checkout the bumped commit | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.bump-version.outputs.sha }} | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| cache: pip | |
| cache-dependency-path: pyproject.toml | |
| - name: Install build backend | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install build twine | |
| - name: Build sdist + wheel | |
| run: python -m build | |
| - name: Verify distribution metadata | |
| run: python -m twine check dist/* | |
| - name: Publish to PyPI | |
| env: | |
| TWINE_USERNAME: __token__ | |
| TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} | |
| run: python -m twine upload --non-interactive dist/* | |
| create-draft-release: | |
| name: Create draft GitHub release | |
| needs: [bump-version, publish-pypi] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout the bumped commit | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.bump-version.outputs.sha }} | |
| - name: Create draft release | |
| # Draft now, attach Nuitka assets in subsequent jobs, unmark | |
| # draft once everything's uploaded — so consumers never see a | |
| # half-finished release. | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: v${{ needs.bump-version.outputs.version }} | |
| name: v${{ needs.bump-version.outputs.version }} | |
| draft: true | |
| generate_release_notes: true | |
| target_commitish: ${{ needs.bump-version.outputs.sha }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| build-nuitka: | |
| name: Nuitka build (windows-x86_64) | |
| needs: [bump-version, create-draft-release] | |
| # Windows is the only platform users have asked for an .exe on. | |
| # Linux/macOS users install from PyPI; shipping a Nuitka binary | |
| # there just inflates the release page without serving a real | |
| # use case. | |
| runs-on: windows-latest | |
| # PySide6 cold builds run ~50-70 min on a fresh runner (Qt is | |
| # a huge amount of C++ to link). With cache they're back to 5-10 | |
| # min. The cap below covers cold + slowest-case parallel link. | |
| timeout-minutes: 90 | |
| env: | |
| ASSET_NAME: autopapertoppt-windows-x86_64.zip | |
| steps: | |
| - name: Checkout the bumped commit | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.bump-version.outputs.sha }} | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| cache: pip | |
| cache-dependency-path: pyproject.toml | |
| - name: Cache Nuitka build artefacts | |
| uses: actions/cache@v4 | |
| with: | |
| # ~/.nuitka holds Nuitka's own caches; *.build holds the | |
| # generated C sources from the previous run. Caching both | |
| # cuts subsequent builds from ~15 min to ~3 min. | |
| path: | | |
| ~/.nuitka | |
| ~/.cache/Nuitka | |
| autopapertoppt.build | |
| autopapertoppt.dist | |
| key: nuitka-${{ runner.os }}-${{ hashFiles('pyproject.toml') }}-${{ needs.bump-version.outputs.version }} | |
| restore-keys: | | |
| nuitka-${{ runner.os }}-${{ hashFiles('pyproject.toml') }}- | |
| nuitka-${{ runner.os }}- | |
| - name: Install runtime + Nuitka | |
| # Install the mcp + gui extras + the parts of intelligence | |
| # that compile cleanly under Nuitka. pymupdf is EXCLUDED from | |
| # the build venv because its Cython binding for MuPDF generates | |
| # a 2.2M-line C file that trips MSVC's per-file heap cap | |
| # (C1002 in pass 2). pypdf alone covers the runtime PDF-text | |
| # path inside the bundle; users who want pymupdf can still | |
| # `pip install autopapertoppt[intelligence]` from PyPI. | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -e ".[mcp,gui]" | |
| pip install pypdf anthropic | |
| pip install nuitka | |
| - name: Compile with Nuitka | |
| # All 11 source plugins are force-included because they're | |
| # imported dynamically by name at runtime (see | |
| # autopapertoppt/fetchers/base.py::load_fetcher). The plugins | |
| # live under sources/<name>/ and are NOT installed as Python | |
| # packages, so we prepend sources/ to PYTHONPATH to make them | |
| # importable during the build. At runtime the app does the | |
| # equivalent sys.path injection itself. | |
| # | |
| # The sources/ directory ALSO ships as data so the runtime can | |
| # read the unmodified .py files for its own sys.path lookup. | |
| # | |
| # python-pptx imports as `pptx` (PyPI name -> module name | |
| # mismatch is normal); use the module name here. | |
| # | |
| # PySide6 is handled ENTIRELY by --enable-plugin=pyside6 — | |
| # the plugin includes the full QML / translations / resources | |
| # tree by default, which is what we want (future tabs may | |
| # use Qt features the current QtWidgets-only Search/Settings | |
| # do not). | |
| # | |
| # Entry point: --python-flag=-m + bare 'autopapertoppt' tells | |
| # Nuitka to treat the build like `python -m autopapertoppt`. | |
| # Passing autopapertoppt/__main__.py directly used to trip | |
| # the "specify its containing directory" warning and made | |
| # sub-imports inside the package resolve oddly. | |
| # | |
| # Distribution model: --standalone produces an | |
| # `autopapertoppt.dist/` folder containing the exe + every | |
| # DLL/SO it needs; we then zip the folder and attach the | |
| # zip to the release. Onefile mode is intentionally NOT | |
| # used — it self-extracts to %TEMP% on every launch, which | |
| # adds startup latency and trips antivirus heuristics on | |
| # locked-down corporate machines. | |
| shell: bash | |
| env: | |
| PYTHONPATH: sources | |
| run: | | |
| python -m nuitka \ | |
| --standalone \ | |
| --python-flag=-m \ | |
| --output-filename=autopapertoppt.exe \ | |
| --windows-icon-from-ico=assets/icon.ico \ | |
| --include-package=autopapertoppt \ | |
| --include-package=arxiv \ | |
| --include-package=semantic_scholar \ | |
| --include-package=openalex \ | |
| --include-package=pubmed \ | |
| --include-package=acm \ | |
| --include-package=ieee \ | |
| --include-package=scholar \ | |
| --include-package=dblp \ | |
| --include-package=crossref \ | |
| --include-package=openaire \ | |
| --include-package=springer \ | |
| --enable-plugin=pyside6 \ | |
| --nofollow-import-to=pymupdf \ | |
| --nofollow-import-to=uvicorn \ | |
| --nofollow-import-to=fastapi \ | |
| --nofollow-import-to=starlette \ | |
| --nofollow-import-to=websockets \ | |
| --nofollow-import-to=streamlit \ | |
| --nofollow-import-to=tornado \ | |
| --lto=no \ | |
| --jobs=2 \ | |
| --include-data-dir=./sources=sources \ | |
| --include-package-data=pptx \ | |
| --include-package-data=openpyxl \ | |
| --assume-yes-for-downloads \ | |
| autopapertoppt | |
| - name: Smoke-test the built executable | |
| # If the binary fails to load any of its bundled plugins, this | |
| # surfaces it immediately rather than at the user's first run. | |
| shell: bash | |
| run: | | |
| ./autopapertoppt.dist/autopapertoppt.exe --version || true | |
| ./autopapertoppt.dist/autopapertoppt.exe --help > /dev/null | |
| - name: Zip the dist folder | |
| # Compress-Archive ships with PowerShell on every Windows | |
| # runner and produces a deterministic zip. We zip the | |
| # CONTENTS of autopapertoppt.dist/ rather than the folder | |
| # itself so unzipping does not nest the binary under an | |
| # extra directory layer. | |
| shell: pwsh | |
| run: | | |
| Compress-Archive -Path autopapertoppt.dist\* ` | |
| -DestinationPath $env:ASSET_NAME ` | |
| -CompressionLevel Optimal | |
| - name: Compute SHA-256 checksum | |
| # Attach the checksum file alongside the zip so users can | |
| # verify what they downloaded matches what CI built. | |
| shell: bash | |
| run: sha256sum "$ASSET_NAME" > "$ASSET_NAME.sha256" | |
| - name: Attach binary + checksum to release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: v${{ needs.bump-version.outputs.version }} | |
| files: | | |
| ${{ env.ASSET_NAME }} | |
| ${{ env.ASSET_NAME }}.sha256 | |
| # The release was created as a draft above; uploading does | |
| # not unmark it. | |
| draft: true | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| publish-release: | |
| name: Mark release as published | |
| needs: [bump-version, build-nuitka] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Unmark draft (publish the release) | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const tag = "v${{ needs.bump-version.outputs.version }}"; | |
| const { owner, repo } = context.repo; | |
| // getReleaseByTag returns 404 for DRAFT releases — per | |
| // https://docs.github.com/rest/releases/releases#get-a-release-by-tag-name | |
| // "You cannot get a draft release by its tag name." | |
| // Enumerate via listReleases (which DOES include drafts) | |
| // and filter by tag name instead. | |
| const releases = await github.paginate( | |
| github.rest.repos.listReleases, | |
| { owner, repo, per_page: 100 }, | |
| ); | |
| const release = releases.find(r => r.tag_name === tag); | |
| if (!release) { | |
| throw new Error( | |
| `No release found with tag ${tag}. Existing: ` + | |
| releases.map(r => r.tag_name).join(", "), | |
| ); | |
| } | |
| await github.rest.repos.updateRelease({ | |
| owner, repo, | |
| release_id: release.id, | |
| draft: false, | |
| }); | |
| console.log(`Released ${tag} (${release.html_url})`); |