Skip to content

Add v1.0.0 release automation (build-verify-publish pipeline, no publish yet) - #2

Merged
SSobol77 merged 3 commits into
mainfrom
chore/release-automation-v1.0.0
Jul 19, 2026
Merged

Add v1.0.0 release automation (build-verify-publish pipeline, no publish yet)#2
SSobol77 merged 3 commits into
mainfrom
chore/release-automation-v1.0.0

Conversation

@SSobol77

@SSobol77 SSobol77 commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Release automation — ready for review until the release order in docs/RELEASE.md is followed. Does not tag, release, or publish anything.

Summary

Adds .github/workflows/release.yml: a fail-closed, version-gated release pipeline for v1.0.0 and beyond.

  • workflow_dispatch builds every artifact and runs every verification step, but never creates a GitHub Release and never publishes to PyPI.
  • A pushed tag matching v*.*.* runs the same build/verification and, only if everything succeeds, creates the GitHub Release and publishes to PyPI via Trusted Publishing (OIDC, no token).
  • Both publishing jobs are gated by a positive allow-list condition (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')), not a workflow_dispatch exclusion — there is no code path by which a manual dispatch can publish.

What's included

  • scripts/verify_release_versions.py — cross-checks the Git tag against blender_mobile_3d/version.py, bl_info, blender_manifest.toml, installers/npm/package.json, installers/python/pyproject.toml, and (post-build) release-manifest.json. Collects every mismatch instead of stopping at the first, and fails closed. Also verifies the tag commit is reachable from origin/main via git merge-base --is-ancestor.
  • A real bug fix found while wiring this up: blender_manifest.toml had no [project] table, so version/name/etc. were silently nested under [build-system] per TOML's own rules — unreadable as top-level metadata. Fixed the file structure; scripts/release_artifacts.py now imports VERSION from blender_mobile_3d/version.py instead of hardcoding it, closing one whole class of version drift.
  • Build job: canonical Blender ZIP + SHA-256 + release manifest, Python wheel + sdist, npm tarball — all from a fresh checkout of the tag. Verifies twine check, the ZIP checksum, the release-manifest schema, and npm tarball contents (rejects test/ in the package; requires bin/, src/, README.md, LICENSE, package.json). Uploads one artifact set (14-day retention) that every downstream job consumes, so nothing is rebuilt inconsistently.
  • Blender headless regression job: downloads those artifacts and runs the core add-on regression suite plus the full installer end-to-end lifecycle (install → no-op update → uninstall guard → uninstall → confirmed absence) for both the wheel and the npm tarball against real Blender.
  • GitHub Release: gh release create with the ambient GITHUB_TOKEN — no third-party release action — attaching the ZIP, checksum, manifest, wheel, sdist, and npm tarball, with generated release notes.
  • PyPI: pypa/gh-action-pypi-publish pinned to a commit SHA, environment: pypi, permissions: id-token: write only. No password, no long-lived API token anywhere in the workflow.
  • npm is intentionally not published by this workflow. The first publish of a new scoped package needs interactive 2FA, and npm Trusted Publishing can't be configured until the package already exists. docs/RELEASE.md documents the exact order: merge → configure PyPI pending publisher → workflow_dispatch dry run → create the v1.0.0 tag → verify the GitHub Release → verify PyPI install → manually publish the first npm package with 2FA → configure npm Trusted Publishing for future versions → verify clean installs from both registries — plus yank/deprecate/rollback procedures for GitHub Releases, PyPI, and npm.
  • Supply chain: every third-party action (checkout, setup-python, setup-node, upload-artifact, download-artifact, gh-action-pypi-publish) is pinned to a full commit SHA with a version comment. Every job has an explicit timeout and least-privilege permissions. The workflow has a concurrency group so overlapping release runs can't race.
  • tests/unit/test_release_workflow.py (68 tests) structurally validates all of the above — permissions, triggers, pinned SHAs (shared check also covers ci.yml), publish guards, job dependencies, environment config, absence of continue-on-error/|| true, absence of hardcoded credentials — without needing to execute the workflow on GitHub Actions.
  • tests/unit/test_verify_release_versions.py (14 tests) covers the version-agreement script directly: matching/mismatching versions across every file, malformed tag formats, release-manifest agreement, and tag-reachability pass/fail using the repo's own root commit as a guaranteed-unreachable fixture.

Verification (all done locally before committing)

  • Full pytest suite: 301 tests passed, 96.6% combined branch coverage on blender_mobile_3d + blender_mobile_3d_installer (gate is ≥90%).
  • ruff format --check, ruff check (full + --select S security subset), mypy — all clean.
  • 65 Node tests (node --test) + ESLint — clean.
  • actionlint on both release.yml and ci.yml — zero findings.
  • Rebuilt the Blender ZIP, Python wheel/sdist, and npm tarball fresh using the exact commands the build job runs; ran twine check, the SHA-256 checksum verification, and the npm package-content inspection logic — all pass.
  • Ran the Blender headless regression suite and the full installer E2E lifecycle (install → update → uninstall → confirm absence) against real Blender 5.2.0 using the freshly built wheel — all pass.
  • Confirmed workflow_dispatch cannot publish: both by static structural test (test_workflow_dispatch_cannot_reach_publishing_jobs) and by manual trace of the if: conditions.
  • Scanned all new/changed files for credentials, os.system, shell=True, || true, continue-on-error — none found. Only secrets.GITHUB_TOKEN (ephemeral, built-in) and id-token: write (OIDC permission, not a credential) appear anywhere in the workflow.

CI: 300 passed, 1 skipped, 96.62% coverage
HEAD: 859bc0b

What still requires GitHub Actions (cannot be verified locally)

  • The workflow has never actually been run on GitHub's infrastructure — no workflow_dispatch was triggered and no tag was pushed as part of this PR, per the task's explicit instruction not to publish or tag anything. ci.yml will run automatically on this PR's push/pull_request events (unrelated to release.yml, which only triggers on workflow_dispatch or a version tag).
  • Live OIDC token exchange with PyPI (requires the pypi environment and pending trusted publisher to be configured first — see docs/RELEASE.md step 2).
  • The real gh release create / pypa/gh-action-pypi-publish steps, which only run on a real tag push.
  • A GitHub-hosted runner's exact Blender 4.3.2 download/behavior (verified locally against Blender 5.2.0 instead, which the add-on also targets).

Explicitly not done in this PR

  • No tag, GitHub Release, npm package, or PyPI project was created.
  • No npm publish step exists in release.yml (intentional — see above).
  • PR is opened as Draft and is not merged.

SSobol77 added 3 commits July 19, 2026 23:08
…d pipeline

Adds .github/workflows/release.yml, a fail-closed release pipeline that
never publishes anything unless triggered by a pushed vX.Y.Z tag:

- workflow_dispatch builds and verifies everything but never creates a
  GitHub Release or publishes to PyPI; the guard on both publishing jobs
  is a positive allow-list (`event_name == 'push' && ref starts with
  refs/tags/v`), not a workflow_dispatch exclusion, so there is no path
  by which a manual run can publish.
- scripts/verify_release_versions.py cross-checks the Git tag against
  blender_mobile_3d/version.py, bl_info, blender_manifest.toml,
  installers/npm/package.json, installers/python/pyproject.toml, and
  (post-build) release-manifest.json, collecting every mismatch rather
  than stopping at the first, and fails closed on any of them. It also
  verifies the tag commit is reachable from origin/main via
  `git merge-base --is-ancestor`, refusing to release from an untrusted
  ref.
- Fixed a real bug found while wiring this up: blender_manifest.toml had
  no `[project]` table, so `version`/`name`/etc. were silently nested
  under `[build-system]` per TOML rules and unreadable as top-level
  metadata. release_artifacts.py now imports VERSION from
  blender_mobile_3d/version.py instead of hardcoding it, removing one
  entire class of drift between the source of truth and the manifest it
  produces.
- Build job produces the canonical Blender ZIP + SHA-256 + release
  manifest, the Python wheel + sdist, and the npm tarball from a fresh
  checkout of the tag; verifies twine, the ZIP checksum, the
  release-manifest schema, and npm tarball contents (rejects test/ in the
  package, requires bin/src/README/LICENSE/package.json); uploads
  everything as one artifact set with a 14-day retention that downstream
  jobs (blender-regression, github-release, publish-pypi) all consume, so
  every job builds from the same checked-out tag rather than trusting a
  prior run's output.
- Blender headless regression job downloads those artifacts and runs the
  core add-on suite plus the full installer E2E lifecycle (install ->
  no-op update -> uninstall guard -> uninstall -> confirmed absence) for
  both the wheel and the npm tarball against real Blender.
- GitHub Release step uses `gh release create` with the ambient
  GITHUB_TOKEN (no third-party release action), attaching the ZIP,
  checksum, manifest, wheel, sdist, and npm tarball with generated notes.
- PyPI publish uses pypa/gh-action-pypi-publish pinned to a commit SHA,
  the `pypi` environment, and `id-token: write` only — no password or
  long-lived token anywhere in the workflow.
- npm publishing is intentionally not part of this workflow: the first
  publish of a new scoped package requires interactive 2FA and Trusted
  Publishing can't be configured until the package exists. docs/RELEASE.md
  documents the exact order (merge -> configure PyPI pending publisher ->
  workflow_dispatch dry run -> tag -> verify release -> verify PyPI
  install -> manual first npm publish with 2FA -> configure npm Trusted
  Publishing -> verify clean installs), plus yank/deprecate/rollback
  procedures for all three surfaces.
- Every third-party action (checkout, setup-python, setup-node,
  upload-artifact, download-artifact, gh-action-pypi-publish) is pinned
  to a full commit SHA with a version comment; every job has an explicit
  timeout and least-privilege permissions; the workflow has a concurrency
  group so overlapping release runs don't race.
- tests/unit/test_release_workflow.py structurally validates the above
  (47 tests: permissions, triggers, pinned SHAs shared with ci.yml,
  publish guards, job dependencies, environment config, no
  continue-on-error/`|| true`, no hardcoded credentials) without needing
  to execute the workflow. tests/unit/test_verify_release_versions.py
  covers the version-agreement script directly (14 tests).

Locally verified before committing: full pytest suite (280 tests, 96.6%
combined branch coverage), ruff format/lint/security, mypy, 65 Node
tests + ESLint, actionlint on both workflow files, a fresh rebuild of the
Blender ZIP/wheel/sdist/npm tarball with the exact build-job commands,
the npm package content inspection, and a full installer E2E run against
real Blender 5.2.0 using the freshly built wheel. Nothing was tagged,
released, or published to npm/PyPI.
ci.yml's packaging-python job checks out with actions/checkout's default
(shallow, depth-1) history. In that state, `git rev-list --max-parents=0
HEAD` returns HEAD itself (the shallow boundary is synthesized as
parentless), so the test's "root commit" collapsed to HEAD, and
"is HEAD an ancestor of HEAD" is trivially true -- the opposite of what
the test asserted. Caught by CI, not locally, since this dev clone has
full history.

Skip the test when the checkout is shallow (git rev-parse
--is-shallow-repository) rather than asserting something a depth-1 clone
cannot actually exercise; verified the skip fires in a real forced
shallow clone (git clone --depth 1 file://...) and that the fix resolves
the failure there while all other tests remain unaffected.
…der archive, fix docs

Four fixes requested in PR #2 review, all verified locally before commit.

1. Node/npm toolchain pinning
   - Replaced `node-version: lts/*` with the shared `env.NODE_VERSION`
     ("22.14.0") in test-node, build, and blender-regression.
   - Added an explicit `npm install -g npm@${{ env.NPM_VERSION }}`
     ("11.5.1") step to each of those three jobs, plus a step printing
     `node --version` / `npm --version`.
   - Verified locally: `npm ci`, `npm run lint`, `npm test` (65 tests),
     and `npm pack` + tarball content inspection all pass under this
     exact pinned Node 22.14.0 / npm 11.5.1 combination.

2. Pinned Python release tooling
   - Added scripts/release-requirements.txt: exact `==` pins for
     build==1.5.0, twine==6.2.0, wheel==0.47.0, setuptools==83.0.0,
     kept separate from pyproject.toml's floating `dev` extras.
   - build job now installs via `-r scripts/release-requirements.txt`
     instead of unpinned `pip install build twine`.
   - Switched to `python -m build --no-isolation`, otherwise PEP 517's
     isolated build environment would silently re-resolve its own
     setuptools/wheel and the pin would have no effect. Verified this
     locally: the built wheel's dist-info/WHEEL records
     "Generator: setuptools (83.0.0)", confirming the pinned version is
     what actually builds the artifact, not a fresh isolated resolution.
   - `twine check` on the resulting wheel/sdist: PASSED.

3. Blender binary verification before execution
   - Independently confirmed the SHA-256 for blender-4.3.2-linux-x64.tar.xz
     two ways: against Blender's own published
     download.blender.org/release/Blender4.3/blender-4.3.2.sha256
     manifest, and by downloading the archive separately and hashing it
     locally (sha256sum and, as a second algorithm, md5sum) -- both
     matched exactly: 4da1c956673c0485e63054e563ee69198cc8f80d8157dd7592dffc8a6a5592e6
   - Pinned that digest directly in the workflow (BLENDER_TARBALL_SHA256
     env var on the download step) rather than re-fetching the .sha256
     manifest from the same server at release time, which would verify
     the archive against itself with no independent guarantee.
   - `sha256sum -c` now runs immediately after download and before
     `tar -xf`; `set -euo pipefail` means a mismatch aborts the step
     before extraction. Verified locally against both the real archive
     (passes) and a deliberately tampered copy (fails closed, nonzero
     exit, archive not extracted).

4. Corrected workflow_dispatch documentation
   - docs/RELEASE.md previously implied `workflow_dispatch` could be
     tried "against the release branch, before merging" -- incorrect:
     GitHub only exposes manual dispatch once the trigger exists on the
     default branch, full stop, regardless of UI vs `gh` CLI vs REST API.
   - Restructured the release order into the exact required sequence:
     merge PR #2 -> configure the `pypi` GitHub environment -> configure
     the PyPI pending trusted publisher -> run workflow_dispatch on
     main -> confirm publish jobs were skipped -> only then create the
     v1.0.0 tag (steps 7-11 renumbered accordingly, internal
     cross-references and anchors fixed).

5. Regression tests (tests/unit/test_release_workflow.py, +21 tests)
   Fail if any of the following regress: a mutable Node selector
   (lts/*, lts/current, latest, current) reappears on a node-version
   line; npm isn't explicitly pinned via env.NPM_VERSION in any of the
   three jobs; node/npm versions aren't printed; release-requirements.txt
   is missing or contains anything other than exact `==` pins for
   build/twine/wheel/setuptools; the old unpinned `pip install build
   twine` pattern reappears; `--no-isolation` is dropped from the wheel
   build; the Blender checksum env var is absent or not a valid 64-hex
   SHA-256; the archive is extracted before `sha256sum -c` runs; the
   checksum manifest is re-fetched from download.blender.org at runtime;
   or docs/RELEASE.md claims a pre-merge dispatch path is possible /
   omits the default-branch requirement / has its step order out of
   sequence.

Preserved without modification: positive tag-only publishing guards,
fetch-depth: 0 for tag reachability, exact semver verification via
verify_release_versions.py, least-privilege permissions, pinned action
SHAs, artifact reuse between jobs (single build -> multiple consumers),
the 90% branch coverage gate, the github-release -> publish-pypi
dependency, and the prohibition on npm publishing in this workflow.

Locally verified before committing: full pytest suite (301 tests, 96.6%
combined branch coverage), the expanded release-workflow structural
suite (82 tests total across both test files), ruff format/lint/security,
mypy, 65 Node tests + ESLint (pinned toolchain), actionlint on both
workflow files, and a from-scratch local rebuild of every release
artifact (Blender ZIP + checksum + manifest, Python wheel/sdist via the
pinned tooling, npm tarball) with checksum verification, twine check, and
npm package content inspection all passing. Nothing was tagged,
released, or published to npm/PyPI.
@SSobol77
SSobol77 marked this pull request as ready for review July 19, 2026 21:46
@SSobol77
SSobol77 merged commit 4345365 into main Jul 19, 2026
14 checks passed
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