Skip to content

Release

Release #13

Workflow file for this run

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})`);