Repository navigation
release #6
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 | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: "Version to release (e.g., 0.0.1)" | |
| required: true | |
| type: string | |
| target: | |
| description: "Publish target" | |
| type: choice | |
| options: [pypi, testpypi] | |
| default: testpypi | |
| permissions: | |
| contents: write | |
| id-token: write # required for PyPI Trusted Publishing (OIDC); npm uses NPM_TOKEN | |
| actions: read # preflight's `gh run list` needs this; omitting it silently returns empty | |
| env: | |
| NODE_VERSION: "24" | |
| PYTHON_VERSION: "3.12" | |
| jobs: | |
| # Fail fast on obvious problems before touching any packaging or git state: | |
| # - malformed version string | |
| # - tag already exists (re-dispatch would silently build on top of it) | |
| # - pr.yml hasn't passed on the commit we're about to release | |
| # The release workflow uses workflow_dispatch, so github.sha == main HEAD | |
| # at dispatch time; pr.yml's push trigger must have already graded that SHA. | |
| preflight: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - name: Validate version string | |
| run: | | |
| echo "${{ inputs.version }}" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+([.-]?(a|b|rc|alpha|beta)[0-9]*)?$' | |
| - name: Fail if tag already exists on origin | |
| run: | | |
| git fetch --tags --quiet | |
| if git rev-parse --verify --quiet "refs/tags/v${{ inputs.version }}" >/dev/null; then | |
| echo "::error::tag v${{ inputs.version }} already exists — choose a new version" | |
| exit 1 | |
| fi | |
| - name: Verify pr.yml is green on ${{ github.sha }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| for i in $(seq 1 30); do | |
| conclusion=$(gh run list \ | |
| --commit "${{ github.sha }}" \ | |
| --workflow pr.yml \ | |
| --branch main \ | |
| --json conclusion,status \ | |
| --jq 'map(select(.status == "completed")) | .[0].conclusion' || true) | |
| case "${conclusion:-}" in | |
| success) | |
| echo "pr.yml passed on ${{ github.sha }}" | |
| exit 0 ;; | |
| failure|cancelled|timed_out|action_required|startup_failure) | |
| echo "::error::pr.yml concluded '${conclusion}' on ${{ github.sha }}" | |
| exit 1 ;; | |
| *) | |
| echo "waiting for pr.yml on ${{ github.sha }} (attempt ${i}/30)" | |
| sleep 10 ;; | |
| esac | |
| done | |
| echo "::error::pr.yml did not complete on ${{ github.sha }} within timeout" | |
| exit 1 | |
| # Bump versions in the working tree (no commit yet), build artifacts, upload. | |
| # The commit + tag only hit origin in `finalize`, after publishes succeed, | |
| # so a failed build never leaves an orphan tag to clean up. | |
| build: | |
| needs: preflight | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - uses: astral-sh/setup-uv@v8.0.0 | |
| with: | |
| python-version: ${{ env.PYTHON_VERSION }} | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Bump all package versions (working tree only) | |
| run: uv run python scripts/bump_version.py "${{ inputs.version }}" | |
| - name: Regenerate npm lockfile | |
| run: npm install --package-lock-only | |
| - name: Build Python wheels + sdists | |
| run: uv build --all-packages --out-dir dist-py | |
| - name: Pack npm tarballs | |
| run: | | |
| mkdir -p dist-npm | |
| for pkg in packages/*/; do | |
| npm pack "$pkg" --pack-destination dist-npm | |
| done | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: dist-py | |
| path: dist-py/ | |
| retention-days: 14 | |
| - uses: actions/upload-artifact@v4 | |
| with: | |
| name: dist-npm | |
| path: dist-npm/ | |
| retention-days: 14 | |
| publish-pypi: | |
| needs: build | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| package: | |
| - simple_module_core | |
| - simple_module_db | |
| - simple_module_hosting | |
| - simple_module_testing | |
| - simple_module_auth | |
| - simple_module_background_tasks | |
| - simple_module_dashboard | |
| - simple_module_datasets | |
| - simple_module_feature_flags | |
| - simple_module_file_storage | |
| - simple_module_permissions | |
| - simple_module_products | |
| - simple_module_settings | |
| - simple_module_users | |
| environment: | |
| name: ${{ inputs.target }} | |
| url: https://${{ inputs.target == 'testpypi' && 'test.' || '' }}pypi.org/project/${{ matrix.package }}/ | |
| steps: | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: dist-py | |
| path: dist-py/ | |
| - name: Filter artifacts for this package | |
| shell: bash | |
| run: | | |
| shopt -s nullglob | |
| files=( dist-py/${{ matrix.package }}-* ) | |
| if [ ${#files[@]} -eq 0 ]; then | |
| echo "::error::no artifacts matched dist-py/${{ matrix.package }}-*" | |
| ls -la dist-py | |
| exit 1 | |
| fi | |
| mkdir -p to-publish | |
| mv "${files[@]}" to-publish/ | |
| ls to-publish | |
| - uses: pypa/gh-action-pypi-publish@release/v1 | |
| with: | |
| packages-dir: to-publish | |
| repository-url: ${{ inputs.target == 'testpypi' && 'https://test.pypi.org/legacy/' || '' }} | |
| publish-npm: | |
| needs: build | |
| if: inputs.target == 'pypi' | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| package: [ui, i18n, tsconfig] | |
| environment: npm | |
| steps: | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: dist-npm | |
| path: dist-npm/ | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Configure npm auth | |
| # Write the registry line ourselves so _authToken reads from NPM_TOKEN | |
| # (setup-node's registry-url would hard-code NODE_AUTH_TOKEN instead). | |
| # The backslash escapes the $ so the literal ${NPM_TOKEN} lands in | |
| # .npmrc and is expanded by npm at publish time. | |
| run: echo "//registry.npmjs.org/:_authToken=\${NPM_TOKEN}" > ~/.npmrc | |
| - name: Publish tarball | |
| shell: bash | |
| env: | |
| NPM_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| run: | | |
| shopt -s nullglob | |
| # `npm pack` on a scoped package writes <scope>-<name>-<version>.tgz with | |
| # the leading @ stripped and '/' replaced by '-'. | |
| files=( dist-npm/simple-module-py-${{ matrix.package }}-*.tgz ) | |
| if [ ${#files[@]} -eq 0 ]; then | |
| echo "::error::no tarball for @simple-module-py/${{ matrix.package }} in dist-npm/" | |
| ls -la dist-npm | |
| exit 1 | |
| fi | |
| npm publish --access public "${files[0]}" | |
| # Only after every publish succeeded do we commit the version bump + tag. | |
| # Skipped for testpypi so we don't burn a version number on a dry-run. | |
| finalize: | |
| needs: [publish-pypi, publish-npm] | |
| if: inputs.target == 'pypi' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.RELEASE_PUSH_TOKEN || github.token }} | |
| - uses: astral-sh/setup-uv@v8.0.0 | |
| with: | |
| python-version: ${{ env.PYTHON_VERSION }} | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Re-apply version bump | |
| run: uv run python scripts/bump_version.py "${{ inputs.version }}" | |
| - name: Regenerate npm lockfile | |
| run: npm install --package-lock-only | |
| - name: Commit, tag, push | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git add -A | |
| if ! git diff --cached --quiet; then | |
| git commit -m "release: v${{ inputs.version }}" | |
| git push origin HEAD:main | |
| else | |
| echo "working tree clean — packages already published at this version, tagging HEAD" | |
| fi | |
| git tag "v${{ inputs.version }}" | |
| git push origin "v${{ inputs.version }}" | |
| smoke: | |
| needs: finalize | |
| if: inputs.target == 'pypi' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - uses: astral-sh/setup-uv@v8.0.0 | |
| with: | |
| python-version: ${{ env.PYTHON_VERSION }} | |
| - name: Install CLI from PyPI (retry for index propagation) | |
| run: | | |
| for i in $(seq 1 12); do | |
| if uv tool install "simple_module_hosting==${{ inputs.version }}"; then | |
| exit 0 | |
| fi | |
| echo "PyPI index not yet showing ${{ inputs.version }} — retry ${i}/12 in 15s" | |
| sleep 15 | |
| done | |
| echo "::error::PyPI never served simple_module_hosting==${{ inputs.version }}" | |
| exit 1 | |
| - name: Generate smoke app | |
| run: uv tool run simple-module new smoke-app --yes --db sqlite --no-install | |
| - name: Install smoke app deps (Python + npm, with propagation retry) | |
| working-directory: smoke-app | |
| run: | | |
| for i in $(seq 1 6); do | |
| if uv sync; then break; fi | |
| echo "uv sync failed — retry ${i}/6 in 15s" | |
| sleep 15 | |
| done | |
| for i in $(seq 1 6); do | |
| if npm install; then break; fi | |
| echo "npm install failed — retry ${i}/6 in 15s" | |
| sleep 15 | |
| done | |
| - name: Run smoke app tests | |
| working-directory: smoke-app | |
| run: uv run pytest -q | |
| # TestPyPI equivalent of `smoke`. Exercises the release path before real PyPI | |
| # sees the version — the whole point of the testpypi target. Python-only: | |
| # npm packages are only published on the pypi target, so the npm deps from | |
| # the generated smoke app can't resolve here. | |
| smoke-testpypi: | |
| needs: publish-pypi | |
| if: inputs.target == 'testpypi' | |
| runs-on: ubuntu-latest | |
| env: | |
| UV_INDEX_URL: https://test.pypi.org/simple/ | |
| UV_EXTRA_INDEX_URL: https://pypi.org/simple/ | |
| steps: | |
| - uses: astral-sh/setup-uv@v8.0.0 | |
| with: | |
| python-version: ${{ env.PYTHON_VERSION }} | |
| - name: Install CLI from TestPyPI (retry for index propagation) | |
| run: | | |
| for i in $(seq 1 12); do | |
| if uv tool install \ | |
| --index-url "$UV_INDEX_URL" \ | |
| --extra-index-url "$UV_EXTRA_INDEX_URL" \ | |
| "simple_module_hosting==${{ inputs.version }}"; then | |
| exit 0 | |
| fi | |
| echo "TestPyPI not yet showing ${{ inputs.version }} — retry ${i}/12 in 15s" | |
| sleep 15 | |
| done | |
| echo "::error::TestPyPI never served simple_module_hosting==${{ inputs.version }}" | |
| exit 1 | |
| - name: Generate smoke app | |
| run: uv tool run simple-module new smoke-app --yes --db sqlite --no-install | |
| - name: Install Python deps from TestPyPI | |
| working-directory: smoke-app | |
| run: | | |
| for i in $(seq 1 6); do | |
| if uv sync; then break; fi | |
| echo "uv sync failed — retry ${i}/6 in 15s" | |
| sleep 15 | |
| done | |
| - name: Run smoke app tests (Python only) | |
| working-directory: smoke-app | |
| run: uv run pytest -q |