Skip to content

ci: automatic releases on merge to master - #24

Closed
odtgit wants to merge 5 commits into
masterfrom
ci/auto-release
Closed

odtgit wants to merge 5 commits into
masterfrom
ci/auto-release

Conversation

@odtgit

@odtgit odtgit commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Summary

Releases are now automatic: every push to master that carries a releasable
commit is versioned, tagged, published to PyPI and shipped as a multi-arch
image, with no manual tag or GitHub release step. See RELEASING.md for the
full walkthrough (prefix table, forcing a bump, re-publishing, recovery, and
the deploy-key setup needed for the release push).

This PR went through an adversarial security review after the first version;
the current design reflects that (see "Security hardening" below).

Flow

PR merged to master (squash, PR title = commit subject)
        |
        v
release.yml: push -> master
  |
  +-- version (read-only): scripts/release.py next-version / pending-retag
  |     no releasable commit -> stop here, nothing else runs
  |
  +-- prepare (read-only, no push credential)
  |     scripts/release.py apply X.Y.Z
  |       -> bump pyproject.toml, config.yaml, manifest.json (if present)
  |       -> prepend CHANGELOG.md, write RELEASE_NOTES.md
  |     uploads `git diff` + release notes as an artifact; commits nothing
  |
  +-- validate-and-push (holds the only push credential: an SSH deploy key,
  |     not GITHUB_TOKEN)
  |     re-validates the raw patch text with inline shell (no repo code):
  |       file allow-list, version-digits-only hunks, changelog is
  |       additions-only
  |     applies, commits "release: X.Y.Z [skip ci]" as the release identity,
  |       pushes to master, tags vX.Y.Z, gh release create
  |       (GITHUB_TOKEN used for exactly that one API call)
  |
  +-- build: checkout vX.Y.Z, build sdist/wheel, twine check,
  |     check_version.py against the tag
  |
  +-- publish-pypi (environment: pypi, OIDC trusted publishing)
  |
  +-- images (reusable images.yml): push ghcr.io/odtgit/evmqtt:X.Y.Z + :latest

ci.yml's own push-to-master run still builds/pushes :edge from the same
commit, independently of release.yml (see "edge staleness" in RELEASING.md).

Image build/push moved into a reusable .github/workflows/images.yml
(workflow_call, inputs ref/push/version/move_latest/
allow_overwrite), called by ci.yml for PR builds (no push, local smoke
test) and master pushes (:edge), and by release.yml for the tagged
release (:X.Y.Z + :latest). A new pr-title-lint.yml (read-only
pull_request_target) enforces the conventional-commit prefix the bump
logic depends on.

Bump table

PR title prefix Bump
<type>!: or a BREAKING CHANGE: footer major
feat: minor
fix:, perf: patch
docs:, chore:, ci:, test:, refactor:, style:, build:, release: none

Security hardening

An adversarial review of an earlier revision returned BLOCK. Addressed:

  • No ruleset bypass for the GitHub Actions app. That bypass would apply
    to every workflow's default GITHUB_TOKEN, not just this job. Push/tag
    instead uses a dedicated SSH deploy key (RELEASE_DEPLOY_KEY, environment
    release, restricted to branch master); GITHUB_TOKEN is used for
    exactly one step (gh release create). scripts/setup_tag_ruleset.sh
    protects refs/tags/v* so only that key can create/update/delete release
    tags. Owner setup steps (ssh-keygen, gh repo deploy-key add,
    gh secret set) are in RELEASING.md; no keys or secrets are created by
    any script or workflow.
  • The push-credentialed job never runs repo code. prepare (read-only)
    runs release.py apply and uploads the diff as an artifact.
    validate-and-push downloads it and validates the raw diff text with
    inline shell only: file allow-list, and per-file, every changed line
    mentions version/evmqtt== and normalising away X.Y.Z digits makes
    the added/removed line sets identical (only version numbers changed);
    CHANGELOG.md must be pure additions. Verified against real patches in a
    scratch repo, including that it rejects an appended stray line, a wholly
    unrelated new file, and a changelog line rewrite.
  • republish_tag hardened: requires the tag to be an ancestor of
    origin/master and its commit to be a release: X.Y.Z [skip ci] commit
    from the release identity matching that version; never moves :latest;
    refuses to overwrite an existing GHCR version tag unless
    force_overwrite_image: true.
  • Race handling: if master moves between version computing a diff
    and validate-and-push applying it, the job fails fast and relies on the
    next push to recompute from the new tip (documented as the deliberately
    simplest option, rather than blindly reapplying a stale diff). If the
    release commit pushes but the tag push fails, the next run detects the
    untagged commit (scripts/release.py pending-retag) and only re-tags.
  • Other fixes: release.py apply rejects non-X.Y.Z versions; a
    non-numeric tag (v1.2.0-rc1) is skipped instead of crashing
    bump_version; changelog/release-note text strips images/HTML and
    escapes @mentions; check_version.py's root override no longer
    silently no-ops (Path("") is truthy, not falsy); every action in
    release.yml/images.yml is now pinned to a commit SHA with a version
    comment (pr-title-lint.yml already was).

What merging this PR releases

Checked with scripts/release.py next-version against this repo's real
history: from v2.1.0 to origin/master's tip, the only commits are ci:
type - non-releasing - so merging this PR by itself ships no release
(its own squash commit title, ci: automatic releases on merge to master,
is also type ci).

If PR #19 (2.0: HACS integration (4c)) merges first or alongside this one:
its current title doesn't carry a conventional prefix, so as titled it also
computes to no release. It contains a feat(hacs)!: breaking-change commit,
so once retitled with a conventional prefix it would trigger a major
release - confirmed by dry-running release.py against a squashed copy of
that branch with a conventional title.

Risks / unverified

  • The GitHub ruleset that will require PRs + a security-review check on
    master is being built by another agent in parallel; this PR documents
    (in RELEASING.md) the deploy-key bypass setup needed for both the
    master ruleset and the new tag ruleset, and ships
    scripts/setup_tag_ruleset.sh for the tag side, but doesn't touch that
    other agent's files. Verified no ruleset exists yet on the live repo.
  • PR feat(hacs): Home Assistant custom integration via HACS #19's current title will fail the new PR title lint once this merges;
    it'll need a conventional prefix before it can land.
  • The full release.yml job graph is actionlint-clean, unit-tested at the
    release.py level, and its patch-validation shell was tested standalone
    against real generated patches (including untracked-file and attack
    cases) - but it only triggers on push-to-master/workflow_dispatch, so it
    couldn't be exercised end-to-end on GitHub without touching master.
    act wasn't available to dry-run it locally either.
  • If gh release create fails after a tag already exists (rather than the
    commit-pushed-but-untagged case, which is handled), there's no automatic
    recovery for the missing GitHub Release page; documented as a manual step
    in RELEASING.md.
  • edge's baked-in version string lags one release behind until the next
    master push rebuilds it (cosmetic, documented in RELEASING.md).

https://claude.ai/code/session_01TJskTZQqRHiHQBc8kE5Lr2

Push-to-master now computes a semver bump from squash-merged PR titles
(feat/fix/perf/breaking!), commits and tags the release, then builds,
publishes to PyPI and pushes the multi-arch image, no manual GitHub
release step. Image build/push is factored into a reusable
images.yml called from both ci.yml (edge, PR smoke test) and
release.yml (semver + latest). A new PR title lint enforces the
conventional-commit prefixes the bump logic depends on.
Declaring packages: write on image-build/merge unconditionally made
GitHub reject any call site that only grants contents: read (PR
builds), since the callee's requested permission exceeded the
caller's ceiling for every invocation, not just the offending one.
Omitting job-level permissions lets each job use whatever the calling
job actually granted for that specific call.
docker/metadata-action produced zero tags for PR builds since all
three explicit rules were disabled (no push, no version); the local
build didn't fail (it uses a hardcoded tag) but emitted a warning and
left BUILD_VERSION/labels empty.
Address adversarial review findings on the auto-release design:

- Split the tag/release job into a read-only "prepare" job (runs
  release.py apply, uploads the diff as an artifact) and a
  "validate-and-push" job that holds the only push credential. The
  latter never runs repo code; it re-validates the raw patch text with
  inline shell (file allow-list, version-digits-only hunks, changelog
  additions-only) before applying, committing and pushing.
- Push/tag via an SSH deploy key (RELEASE_DEPLOY_KEY, environment
  "release", persist-credentials: false) instead of a ruleset bypass
  for the GitHub Actions app - that bypass would apply to every
  workflow's default token, not just this one job. GH_TOKEN is used
  for exactly one step (gh release create).
- scripts/setup_tag_ruleset.sh: protect refs/tags/v* so only that
  deploy key can create/update/delete release tags. Documented owner
  setup (ssh-keygen, deploy key, secret, ruleset) in RELEASING.md; no
  keys or secrets are created by any script.
- republish_tag now requires the tag to be an ancestor of
  origin/master and its commit to be a release: X.Y.Z commit from the
  release identity, never moves :latest, and refuses to overwrite an
  existing GHCR version tag unless force_overwrite_image is set
  (images.yml gained move_latest/allow_overwrite inputs and an
  overwrite guard).
- Race handling: fail fast and rely on the next push if master moved
  while the diff was being computed (documented as the chosen,
  simplest option); detect and recover an untagged release commit
  (commit pushed, tag push failed) via pending-retag.
- release.py: reject non-X.Y.Z versions in apply; skip non-numeric
  v* tags instead of crashing bump_version; sanitize changelog/release
  note text (strip images/HTML, escape @mentions).
- check_version.py: fix ROOT override falling through even when set,
  since Path("") is truthy, not falsy.
- Pin every action in release.yml and images.yml to a commit SHA with
  a version comment (pr-title-lint.yml was already pinned).
plain `git diff` ignores untracked files, so the first release ever
(when CHANGELOG.md doesn't exist yet) would silently drop it from the
uploaded patch. git add -N (intent-to-add) makes new files show up in
the diff without staging their content.

Verified against a scratch repo with no pre-existing CHANGELOG.md:
before the fix, git diff only showed pyproject.toml/config.yaml; after,
it includes the new CHANGELOG.md, and the validate-and-push job's hunk
checks still pass.
@odtgit

odtgit commented Sep 28, 2026

Copy link
Copy Markdown
Owner Author

Dropped: rescoped as over-engineered for this repo.

@odtgit odtgit closed this Sep 28, 2026
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