Skip to content

chore(ci): trunk-based CI + one-button release - #237

Merged
JonnyTran merged 6 commits into
developfrom
chore/trunk-based-ci
Aug 6, 2026
Merged

chore(ci): trunk-based CI + one-button release#237
JonnyTran merged 6 commits into
developfrom
chore/trunk-based-ci

Conversation

@JonnyTran

@JonnyTran JonnyTran commented Aug 5, 2026

Copy link
Copy Markdown
Member

Converts the monorepo from gitflow to trunk-based, and replaces the ~15-step manual release with one dispatch.

Pairs with Extralit/extralit-hf-space#7, which teaches the Space repo to accept the new dispatch payload. That one merges first.

⚠️ This is inert on develop

Nothing here changes behaviour until you flip the trunk. The new triggers name only main, release, v* tags and pull_request — merging this to develop fires no workflow, moves no image, and deploys nothing. extralit/public-demo stays frozen on its current image until someone runs release.yml deliberately.

The model

Ref Images Deploys to PyPI
main (trunk, default) extralitdev/*:main + :latest, amd64 extralit-dev/develop
release (long-lived) extralit/*:vX.Y.Z + :latest, amd64+arm64 extralit/public-demo
vX.Y.Z tag versioned docs extralit, extralit-server
PR extralitdev/*:pr-N, amd64 extralit-dev/pr-N

release is always a tagged point on main's history: the cut pushes one stamp commit to main, release, and the tag in a single atomic push, so all three agree. Rollback is re-pointing release at an older tag.

What changed

Releases. scripts/bump_version.py is now the single owner of the version, which lived hand-synced in three files. release.yml cuts a release:

gh workflow run release.yml -f version=0.7.0                    # rehearse — read the plan table
gh workflow run release.yml -f version=0.7.0 -f dry_run=false   # cut it

dry_run defaults to true. plan is read-only and prints what it would do; it also refuses to run if CI isn't green on the base commit (-f skip_ci_check=true to override), and detects a converged re-run so recovery is safe. github-release.yml waits until PyPI actually serves both packages before creating the GitHub Release, so a release is never announced before it's installable.

Triggers. push narrows to main + release (+ v* tags on the publishing workflows) and pull_request is added. Today a feat/** push runs CI but a PR from a fork runs nothing — that inverts. develop, feat/** and releases/** are retired; releases/** was broken anyway (a push there dispatched branch=releases/v0.6.1, which fell through resolve-env and created a PR-preview Space).

PRs run tests and nothing else. build_docker_images collapses to github.ref_type == 'branch' && github.event_name != 'pull_request'. PR preview images already have an owner — extralit-frontend.build-push-dev.yml, gated on ready_for_review and the only caller that passes pr_number. Building here too would double-dispatch on that event and, with no pr_number, move extralitdev/*:latest from unreviewed code. The ref_type half stops a tag push building a stray dev image and dispatching branch=vX.Y.Z, which resolve-env would turn into a junk preview Space.

The draft == false guard is gone from both build jobs: null == false is true in GitHub expressions so it never gated pushes anyway, and a draft PR should get test feedback while it's still being iterated on.

Publishing moves from a branch to a tag. publish_release fired on every push to main; under trunk that's a PyPI publish per merge. It's now startsWith(github.ref, 'refs/tags/v'). Its needs on build_docker_images is dropped — that job is skipped on tag pushes, so the edge would have skipped the publish with it.

Fixes a live bug. build-docker-images.yml derived the image tag by grepping _version.py, so a main build silently overwrote an existing v0.6.1 image whenever the file wasn't bumped — and no workflow ever created a git tag. All three grep '__version__' | cut incantations now call bump_version.py check.

Concurrency moves from github.sha to github.ref. The release's atomic push lands main and release at the same SHA, so a sha-keyed group made those runs cancel each other at random — if the release run lost that race the production build was silently killed.

Docs are retargeted: docs/architecture/deployment.md (which also had ~10 links to extralit-pr-preview.yml, a file that does not exist, and still claimed the server compiles the frontend from source with BASE_URL=@@baseUrl@@ — untrue since the Nuxt 4 migration), release_guide.md, contributor.md, developer.md, CLAUDE.md, and gen_changelog.py (which read the published changelog from develop regardless of which ref built the docs).

mike aliases follow the model: mainlatest + default, vX.Y.ZX.Y + stable.

Verification

  • actionlint clean on every touched workflow. The only remaining findings are pre-existing and in files this PR doesn't touch (steps.meta undefined in build-docker-images.yml, stale action versions in the teardown/stale workflows).
  • shellcheck isn't available to actionlint here, so every non-trivial run: body was extracted from the YAML and executed under bash against stubbed env: 26 cases across release.yml covering version validation (including 0.7.0 ; rm -rf /), all three convergence branches, green/failing/pending/cancelled/zero-run CI states, the plan summary in four modes, and a real atomic push into a bare local remote asserting all three refs land at one SHA. 10 more cases cover the hf-space resolve-env rewrite in [Snyk] Security upgrade nuxt from 2.18.1 to 3.12.4 #7.
  • python3 scripts/bump_version.py check0.6.1 across all three files; round-trips through set/check --expect.

Before merging / after merging

Ordered — the monorepo flip has to happen after the Space repo can accept the new payload.

  1. Merge extralit-hf-space#7 into its main (prefer a merge commit; it carries 13 develop commits). Nothing deploys — that repo is dispatch-driven only.
  2. Rename the GitHub Environments in Extralit/extralit-hf-space: mainproduction, developstaging. Both already hold the right targets; only the names encode gitflow. Secret values can't be read back, so 6 secrets must be re-entered by hand — this is the only irreducibly manual step in the migration. Do this before merging [Snyk] Security upgrade nuxt from 2.18.1 to 3.12.4 #7: the workflow names those environments, and until they exist the deploy jobs resolve empty vars.*.
  3. gh repo edit Extralit/extralit-hf-space --default-branch main, then delete its develop.
  4. Merge this PR onto develop, then re-point the submodule at hf-space main (it currently points at chore/trunk-based-deploys' head, 35086a6, so this PR is reviewable as a whole).
  5. Retarget open PRs: gh pr list --base develop --json number -q '.[].number' | xargs -I{} gh pr edit {} --base main
  6. Flip the trunk: git push origin origin/develop:main. This is a fast-forward — git rev-list --count origin/develop..origin/main is 0, so main is a strict ancestor. Fires extralit-server.yml on main → dev images → extralit-dev/develop. Production untouched.
  7. Default branch → main, delete develop, add branch protection (require PR), and grant the release PAT a protection bypass so the atomic push can land.
  8. First release: gh workflow run release.yml -f version=0.7.0, read the plan, then -f dry_run=false.
  9. Drop the develop migration alias from the hf-space resolve-env once step 6 is verified.

⚠️ On branch protection (step 7)

Don't mark extralit-server / extralit-frontend / extralit as required checks. All three keep a paths: filter, so a PR that doesn't touch that subtree never starts the workflow and the required check sits pending forever — an unmergeable PR with nothing to fix. Require the PR itself; required checks would need a companion always-runs job that reports success on the skip path.

Follow-ups (deliberately not in this PR)

  • Trusted Publishing. permissions: id-token: write is already declared in both publishing workflows but unused. uv publish --trusted-publishing always would drop AR_PYPI_API_TOKEN entirely. Needs a one-time publisher registration on PyPI per project, so this PR keeps the token path.
  • Dead workflows. .github/actions/generate-credentials and slack-post-credentials are referenced by no workflow, and extralit-frontend.teardown{,-all}-pr-environment{,s}.yml target a dead argilla-ci GCP project with their real triggers commented out.
  • steps.meta.outputs.labels in build-docker-images.yml references a step that doesn't exist and silently resolves to empty.
  • The version stamp is committed by the release workflow rather than derived (hatch-vcs) — a deliberate choice, revisitable.

Summary by CodeRabbit

  • New Features

    • Added automated release workflows with version validation, dry runs, atomic promotion, tagging, publishing, and generated release notes.
    • Added centralized version management across application packages and the frontend.
    • Updated CI/CD to support main, release, and version-tag workflows for frontend, documentation, container, and package publishing.
    • Standardized frontend builds and releases around the main and release branches.
  • Documentation

    • Updated contributor, developer, deployment, and release guidance for trunk-based development and automated releases.
    • Updated repository links and branch references to use main.

The version lives in three hand-synced files (the SDK and server _version.py
plus the frontend package.json) and nothing enforced that they agree. Three
workflows each grepped _version.py with their own
`grep '__version__' | cut -d'"' -f2` incantation.

  check                  print the version, fail if the three disagree
  check --expect X.Y.Z   post-condition assertion
  set --version X.Y.Z    rewrite all three

`check` prints the bare version to stdout and diagnostics to stderr so CI can
capture it directly. Paths resolve from __file__, not the CWD, because the
workflow steps that call it run from extralit-server/ and extralit/.

package.json is read via json.loads (unambiguous) but written via an anchored
regex so the file's formatting survives; the result is re-parsed to prove the
substitution hit the top-level "version" key rather than a nested one. `set`
re-reads every file afterwards so a partial rewrite can't report success.
Rewrites the release guide around the one-button release.yml dispatch
(~15 manual steps -> two commands) and adds rollback plus recovery notes for
the two preconditions the workflow enforces.

Rewrites docs/architecture/deployment.md for the new model and fixes three
things that were already wrong, independent of this migration:

- 10 links pointed at .github/workflows/extralit-pr-preview.yml, which does
  not exist. The real file is extralit-frontend.build-push-dev.yml (its name:
  is "Deploy PR preview HF Space").
- It claimed the server "compiles the frontend from source" with
  BASE_URL=@@baseurl@@. That stopped being true at the Nuxt 4 migration: the
  server downloads a prebuilt SPA artifact via download-frontend-artifact,
  built at BASE_URL=/ because the placeholder breaks Nuxt 4's prerender
  crawler.
- The environment tables used the pre-rename main/develop names.

Also fixes gen_changelog.py, which read the published changelog from develop
regardless of which ref built the docs, and repoints every tree/develop and
blob/develop URL across the docs and README at main.

The only doc link that does not yet resolve is .github/workflows/release.yml,
added later in this branch.
Triggers: `push` narrows to `main` and `release` (plus `v*` tags on the two
publishing workflows) and `pull_request` is added, so a PR from any branch —
fork included — gets CI. `develop`, `feat/**` and `releases/**` are retired.

PRs run tests and nothing else. `build_docker_images` collapses to
`github.ref_type == 'branch' && github.event_name != 'pull_request'`: PR preview
images already belong to extralit-frontend.build-push-dev.yml (gated on
`ready_for_review`, and the only caller that passes `pr_number`), so building
here too would double-dispatch on that event and — with no `pr_number` — move
`extralitdev/*:latest` from unreviewed code. The `ref_type` half stops a tag
push building a stray dev image and dispatching `branch=vX.Y.Z`, which
`resolve-env` would turn into a junk preview Space.

The `draft == false` guard is gone from both build jobs: `null == false` is true
in GitHub expressions so it never gated pushes anyway, and a draft PR should get
test feedback while it is still being iterated on.

`is_release`/`publish_latest` now key off the `release` branch rather than
`main`, and `publish_release` fires only on `refs/tags/v*` — under trunk a
branch-gated publish would try to release on every merge. Its `needs` on
`build_docker_images` is dropped: that job is skipped on tag pushes, so the edge
would have skipped the publish with it.

`concurrency.group` moves from `github.sha` to `github.ref` so the release's
atomic push of `main` + `release` + tag doesn't make the three runs cancel each
other.

The prebuilt SPA always comes from `main`, and the `with:` blocks are dropped in
favour of the action's default. `dawidd6/action-download-artifact` needs a
previously successful run on the branch it names, so sourcing from `release`
would fetch the previous release's frontend and hard-fail on the first release.

The three hand-rolled `grep '__version__' | cut` incantations are replaced with
`scripts/bump_version.py check`.
release.yml is the whole release cut in one dispatch. `authorize` requires
admin/maintain and is inert outside Extralit/extralit. `plan` is read-only:
it validates X.Y.Z, resolves the base commit on `main`, detects a converged
re-run (tag exists AND its commit is stamped with that version — anything else
is a collision a human resolves), asserts CI is green on the base commit while
filtering out its own run so it can't deadlock, and renders the plan into the
step summary. `cut` only runs on `-f dry_run=false`, stamps via
scripts/bump_version.py, and pushes `main`, `release` and the tag with a single
`git push --atomic` — a stale `main` fails the whole push rather than leaving a
tag with no branch. It pushes with a PAT because GITHUB_TOKEN pushes emit no
downstream events, which would leave the tag triggering nothing.

github-release.yml runs zero project code — no checkout, no install, no build.
It holds `contents: write` and fires on a tag, so a malicious tagged commit must
have nothing there to execute. It polls PyPI for both packages before running
`gh release create --verify-tag --generate-notes`, so a release is never
announced before it is installable, and re-runs skip an existing release.

.github/release.yml categorises the generated notes. GitHub reads PR *labels*,
not title prefixes, so the file says so rather than implying `feat:` works.

extralit.docs.yml swaps the aliases for the trunk model: `main` becomes `latest`
and the default, the tag deploys `X.Y` and moves `stable` onto it, and the
`develop` step is gone. Its `github.head_ref` interpolation is bound to env on
the way past.
@JonnyTran
JonnyTran requested a review from a team as a code owner August 5, 2026 20:02
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
extralit-frontend Ignored Ignored Preview Aug 5, 2026 10:57pm

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR moves repository automation from develop to main and release, adds synchronized version management, introduces automated release workflows, updates deployment routing, and revises related documentation and references.

Changes

Release and deployment migration

Layer / File(s) Summary
Synchronized version management
scripts/bump_version.py, .github/workflows/extralit.yml, .github/workflows/extralit-server.yml, .github/workflows/extralit-server.build-docker-images.yml
Added a CLI to check and update versions across the Python packages and frontend manifest. Workflows now use it for version detection and publishing.
Automated release orchestration
.github/workflows/release.yml, .github/workflows/github-release.yml, .github/release.yml, extralit/docs/community/release_guide.md, CLAUDE.md
Added authorized, validated, optionally dry-run release execution with atomic pushes. Added PyPI polling and generated GitHub releases.
Branch-based workflow routing
.github/workflows/*.yml, .github/actions/download-frontend-artifact/action.yml
Updated triggers, concurrency groups, frontend artifact sources, Docker builds, package publishing, and documentation deployment for main, release, pull requests, and version tags.
Deployment model and operational guidance
docs/architecture/deployment.md
Documented trunk-based staging, release production routing, frontend artifacts, pull-request previews, environment propagation, image rules, recovery, and release credentials.
Branch and repository reference updates
README.md, extralit/docs/community/*, extralit/docs/scripts/gen_changelog.py, extralit-hf-space
Updated branch references, contributor guidance, documentation links, changelog retrieval, the README logo URL, and the HF Space submodule pointer.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ReleaseWorkflow
  participant GitRefs
  participant CIWorkflows
  participant PyPI
  participant GitHubRelease
  ReleaseWorkflow->>GitRefs: atomically push main, release, and version tag
  GitRefs->>CIWorkflows: trigger downstream workflows
  CIWorkflows->>PyPI: publish extralit and extralit-server
  GitHubRelease->>PyPI: poll for both tag versions
  GitHubRelease->>GitRefs: verify the pushed tag
  GitHubRelease->>GitHubRelease: create published release with generated notes
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: trunk-based CI and a one-button release workflow.
Description check ✅ Passed The description is detailed and covers the change scope, release model, verification, migration steps, and follow-ups.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/trunk-based-ci

Comment @coderabbitai help to get the list of available commands.

…able version

Two review findings, both real.

release.yml's `cut` checked out with the PAT and actions/checkout's default
`persist-credentials: true`, which writes it into .git/config — and the very
next step runs repository code (bump_version.py). That token pushes straight to
main/release/tags past branch protection, which is the exact threat
github-release.yml is designed against. The checkout now sets
`persist-credentials: false` and the push step supplies the credential in its
own URL, so no step that executes repo code holds it.

extralit.docs.yml deployed `stable` as an alias, but on the live gh-pages branch
`stable` is an existing *version* (versions.json, published by the pre-trunk
`mike deploy stable`). mike refuses an alias that collides with a version name,
and --update-aliases only re-points names that are already aliases, so the first
tagged release would have failed outright and published no versioned docs. The
tag step now retires that version first, guarded on `mike list --json` so it is a
one-time migration and a permanent no-op afterwards. Verified the predicate
against the real versions.json and against the post-migration shape.

Also: mike deploys `vX.Y` (matching the published v0.6/v0.5/v0.4), but the
release guide and the release summary both pointed at docs.extralit.ai/0.7/.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
.github/workflows/extralit.yml (1)

176-182: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Add strict mode to the version lookup.

The command substitution hides a non-zero exit from bump_version.py, and PACKAGE_VERSION becomes empty.

🛡️ Proposed fix
         run: |
+          set -euo pipefail
           PACKAGE_VERSION=$(python3 "$GITHUB_WORKSPACE/scripts/bump_version.py" check)
+          test -n "$PACKAGE_VERSION"
           PACKAGE_NAME="extralit"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/extralit.yml around lines 176 - 182, Enable strict shell
failure handling at the start of the “Read package info” run block so a non-zero
exit from bump_version.py check stops the step instead of exporting an empty
PACKAGE_VERSION. Preserve the existing PACKAGE_NAME and environment-file
assignments.
.github/workflows/extralit-server.yml (1)

205-211: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Add strict mode to the version lookup.

The command substitution hides a non-zero exit from bump_version.py. PACKAGE_VERSION then becomes empty, and the "Test Installing" step installs an unpinned package. Add set -euo pipefail and assert the value.

🛡️ Proposed fix
         run: |
+          set -euo pipefail
           PACKAGE_VERSION=$(python3 "$GITHUB_WORKSPACE/scripts/bump_version.py" check)
+          test -n "$PACKAGE_VERSION"
           PACKAGE_NAME="extralit-server"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/extralit-server.yml around lines 205 - 211, Update the
“Read package info” step to enable strict shell mode with set -euo pipefail
before invoking bump_version.py, then validate that PACKAGE_VERSION is non-empty
before exporting it so the workflow fails instead of continuing with an unpinned
package.
.github/workflows/extralit-server.build-docker-images.yml (1)

35-40: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Three version lookups discard the exit status of bump_version.py. Each step assigns PACKAGE_VERSION=$(... bump_version.py check) in a run: body without set -euo pipefail. A command substitution does not propagate a non-zero exit, so a script failure or a version-file disagreement yields an empty PACKAGE_VERSION and the step still succeeds. Add strict mode and a non-empty assertion at each site.

  • .github/workflows/extralit-server.build-docker-images.yml#L35-L40: add set -euo pipefail and test -n "$PACKAGE_VERSION". This site has the largest impact: on the release branch an empty value tags the published image v and moves :latest onto it.
  • .github/workflows/extralit-server.yml#L205-L211: add the same two lines so the "Test Installing" step cannot install an unpinned extralit-server.
  • .github/workflows/extralit.yml#L176-L182: add the same two lines so the "Test Installing" step cannot install an unpinned extralit.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/extralit-server.build-docker-images.yml around lines 35 -
40, Update the run blocks assigning PACKAGE_VERSION from bump_version.py check
in .github/workflows/extralit-server.build-docker-images.yml lines 35-40,
.github/workflows/extralit-server.yml lines 205-211, and
.github/workflows/extralit.yml lines 176-182: enable strict shell mode with set
-euo pipefail and assert PACKAGE_VERSION is non-empty with test -n
"$PACKAGE_VERSION" before using it.
🧹 Nitpick comments (7)
CLAUDE.md (2)

83-88: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Clarify the main deployment target.

The table maps main to extralit-dev/develop. The section states that no develop branch exists. The name refers to a Hugging Face Space slug, not a branch, but the reader must infer that. Add the distinction.

♻️ Proposed change
-| `main` | trunk; every merged PR | `extralit-dev/develop` (dev HF Space) |
+| `main` | trunk; every merged PR | the `extralit-dev/develop` HF Space (a Space slug, not a branch) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` around lines 83 - 88, Clarify the `main` row in the deployment
table by explicitly identifying `extralit-dev/develop` as a Hugging Face Space
slug rather than a Git branch, while preserving the existing deployment target
and the statement that no `develop` branch exists.

100-102: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use uv run for the version command.

Line 61 of this same file requires running Python scripts with uv run <script-name>.py. Line 102 uses bare python. Make the two consistent.

♻️ Proposed change
 That stamps the version via `scripts/bump_version.py` and pushes `main`, `release`, and the
 tag atomically. The version lives in three files — always change it with
-`python scripts/bump_version.py set --version X.Y.Z`, never by hand.
+`uv run scripts/bump_version.py set --version X.Y.Z`, never by hand.

As per coding guidelines: "Run Python scripts and tools through uv, such as uv run <script> and uv run <tool>."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` around lines 100 - 102, Update the versioning instruction in
CLAUDE.md to invoke scripts/bump_version.py through uv run instead of bare
python, preserving the existing set --version X.Y.Z arguments and guidance to
avoid manual edits.

Source: Coding guidelines

scripts/bump_version.py (2)

4-6: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the commands with uv run.

The usage examples call python directly. The repository guideline requires running Python scripts through uv. Update the examples so contributors copy the supported invocation.

♻️ Proposed documentation change
-    python scripts/bump_version.py check                  # print the version, fail if they disagree
-    python scripts/bump_version.py check --expect 0.7.0   # post-condition assertion
-    python scripts/bump_version.py set --version 0.7.0    # rewrite all three
+    uv run scripts/bump_version.py check                  # print the version, fail if they disagree
+    uv run scripts/bump_version.py check --expect 0.7.0   # post-condition assertion
+    uv run scripts/bump_version.py set --version 0.7.0    # rewrite all three

As per coding guidelines: "Run Python scripts and tools through uv, such as uv run <script> and uv run <tool>."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/bump_version.py` around lines 4 - 6, Update the command examples in
the usage documentation for bump_version.py to invoke the script through uv run
instead of python, preserving the existing check, --expect, and set --version
arguments.

Source: Coding guidelines


107-109: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Annotate die as NoReturn.

die always raises SystemExit, but the annotation says -> None. Type checkers therefore treat read() as able to return None after a die(...) call, and they cannot prune the error branches. NoReturn makes the control flow explicit.

♻️ Proposed change
-def die(message: str) -> None:
+def die(message: str) -> NoReturn:
     print(f"error: {message}", file=sys.stderr)
     raise SystemExit(1)

Add the import:

 from pathlib import Path
+from typing import NoReturn
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/bump_version.py` around lines 107 - 109, Update the return annotation
of die to NoReturn, and add the required typing import so type checkers
recognize that every call exits by raising SystemExit.
extralit/docs/community/release_guide.md (1)

57-59: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The tag deletion command reports failure when no local tag exists.

git tag -d v0.7.0 exits non-zero if the tag was never fetched locally. && then makes the whole line look like a failed recovery, although the remote tag was already deleted. Separate the two commands.

♻️ Proposed change
-git push origin :refs/tags/v0.7.0 && git tag -d v0.7.0
+git push origin :refs/tags/v0.7.0   # delete the remote tag
+git tag -d v0.7.0 || true           # and the local one, if you have it
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@extralit/docs/community/release_guide.md` around lines 57 - 59, Update the
release-guide tag cleanup command so remote deletion and local tag deletion run
independently rather than being chained with &&. Preserve both operations and
ensure a missing local v0.7.0 tag cannot make the overall recovery command
appear to fail.
.github/workflows/github-release.yml (1)

30-52: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

The two waits are serial, so the worst case approaches the job timeout.

Each package waits up to about 30 minutes, and the loop runs one package after the other. The worst case is about 59 minutes, plus the failure path adds no further wait. timeout-minutes: 90 covers it. Consider polling both packages in one loop so a slow extralit publish does not consume the budget for extralit-server.

♻️ Proposed change
-          for pkg in extralit extralit-server; do
-            echo "::group::Waiting for $pkg==$version"
-            for attempt in $(seq 1 60); do
-              if curl -fsS -o /dev/null "https://pypi.org/pypi/$pkg/$version/json"; then
-                echo "$pkg==$version is live on PyPI (attempt $attempt)."
-                break
-              fi
-              if [[ "$attempt" -eq 60 ]]; then
-                echo "::endgroup::"
-                echo "::error::$pkg==$version never appeared on PyPI after 30 min. \
-          Check the publish job on this tag; the GitHub Release is deliberately not created."
-                exit 1
-              fi
-              sleep 30
-            done
-            echo "::endgroup::"
-          done
+          pending="extralit extralit-server"
+          for attempt in $(seq 1 60); do
+            still=""
+            for pkg in $pending; do
+              if curl -fsS -o /dev/null "https://pypi.org/pypi/$pkg/$version/json"; then
+                echo "$pkg==$version is live on PyPI (attempt $attempt)."
+              else
+                still="$still $pkg"
+              fi
+            done
+            pending="${still# }"
+            [[ -z "$pending" ]] && break
+            if [[ "$attempt" -eq 60 ]]; then
+              echo "::error::$pending never appeared on PyPI after 30 min. \
+          Check the publish job on this tag; the GitHub Release is deliberately not created."
+              exit 1
+            fi
+            sleep 30
+          done
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/github-release.yml around lines 30 - 52, Update the “Wait
for PyPI to serve this version” step so extralit and extralit-server are polled
concurrently within a shared 60-attempt loop, rather than waiting for each
package serially. Track each package’s served state, stop once both are
available, and preserve the existing success logging, 30-second polling
interval, and failure behavior identifying any package still unavailable.
.github/workflows/release.yml (1)

232-238: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

git tag fails when the tag already exists locally.

fetch-depth: 0 fetches tags. The step comment anticipates a re-run after a partial failure. If a previous run pushed the tag but failed later, plan reports already_done=true and skips cut, so the common case is covered. If the tag exists locally but the release is not converged, plan already exits with an error. The remaining gap is a tag created in an earlier run of the same job after a stamped commit, where git tag aborts with "already exists" rather than a clear message. Add -f scoped to the same commit, or check first.

♻️ Proposed change
-          git tag "$TAG"
+          # Idempotent: plan has already proven any existing tag is either converged or fatal.
+          git tag -f "$TAG"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 232 - 238, Update the tagging
command after the commit-or-reuse logic to handle an existing local tag safely:
force-update "$TAG" only when it already points to the current HEAD, or
otherwise check for the tag and reuse it. Preserve the existing failure behavior
for tags pointing to a different commit, while allowing reruns after a partial
release.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/release.yml:
- Around line 9-12: Update the exclude.authors list in the release configuration
to replace the dependabot entry with the exact GitHub login dependabot[bot],
while preserving the existing github-actions[bot] exclusion.

In @.github/workflows/extralit-server.yml:
- Around line 15-21: The tag filters in .github/workflows/extralit-server.yml
lines 15-21 and .github/workflows/extralit.yml lines 15-23 must use the glob
"v*" instead of the invalid regex-style pattern; update each workflow’s publish
job to validate that the triggering tag exactly matches the vMAJOR.MINOR.PATCH
format before publishing.

In @.github/workflows/extralit.docs.yml:
- Around line 79-84: Update the version normalization in the “Deploy Extralit
docs” workflow step so the value passed to mike deploy removes the leading v
from TAG_VERSION before extracting the major and minor components. Keep the
stable alias deployment and existing release formatting unchanged.

In @.github/workflows/release.yml:
- Around line 240-245: Update the atomic git push in the release workflow to
force-update refs/heads/release while keeping refs/heads/main as a non-force
update. Preserve the atomic push and tag update so stale main still aborts the
entire operation, while release can be deliberately moved after rollback or
manual history divergence.
- Around line 117-129: Update the release workflow’s tag-validation logic to
avoid checking out or executing code from $TAG: set checkout credentials to
persist-credentials: false, perform the validation from $BASE_SHA, and read the
tagged version directly with git show "$TAG:extralit/src/extralit/_version.py".
Replace the scripts/bump_version.py check with comparison of that file’s version
to $VERSION while preserving the existing already_done and collision outcomes.

In `@CLAUDE.md`:
- Around line 90-91: Update the release-notes statement near the branch and PR
title conventions to match the categorization configured in .github/release.yml:
generated notes use pull-request labels, not feat:/fix:/docs:/chore: title
prefixes. Clarify that the title convention is not enforced for release-note
grouping while preserving the existing branch and title naming guidance.

In `@docs/architecture/deployment.md`:
- Around line 445-450: Update the deployment documentation paragraph describing
the SPA baked into the server image to state that the next server build uses the
latest successful `main` frontend artifact, replacing the branch-specific
wording while preserving the surrounding deployment behavior.
- Line 110: Update the deployment documentation entries covering preview
overview, operational notes, and the end-to-end procedure to state that previews
run only for non-fork PRs because fork PRs are skipped when required secrets are
unavailable. Apply this clarification consistently to the referenced workflow
descriptions and preserve the existing server/frontend and manual-trigger
behavior.
- Around line 311-315: Update the deploy-pr-space configuration and its
documented “Propagates config” behavior to stop forwarding staging EXTRALIT_*
secrets or variables into PR Spaces, including via add_space_secret and
add_space_variable. Run previews without secrets, or replace them with per-PR
least-privilege credentials that expire automatically; preserve strict filtering
and key-only logging for any non-sensitive configuration that remains.

In `@extralit/docs/community/contributor.md`:
- Line 78: Update the branch naming guidance in the contributor workflow to use
the required feat/, fix/, and docs/ prefixes instead of feature/ and bug/. Also
mention that PR titles should use matching feat:, fix:, or docs: prefixes, with
chore: allowed for maintenance work.

In `@extralit/docs/community/release_guide.md`:
- Around line 55-69: Update the three quoted failure messages in the release
guide to match the actual output emitted by the release workflow: the tag
message should mention that its commit is not stamped 0.7.0, the CI message
should include the commit SHA and workflow names, and the authorization message
should include the actor and required/current roles; alternatively, remove
quotation marks and describe these conditions generically.
- Around line 71-79: Update the “Roll back” instructions to fetch the current
remote state with `git fetch origin` before using `--force-with-lease`, and
document that the subsequent non-force release push requires the restored
release commit to remain an ancestor of the next release commit; alternatively,
adjust the release workflow’s push behavior around its release publishing step
to support the documented rollback.

In `@scripts/bump_version.py`:
- Around line 68-70: Update BumpVersion.write to check whether self.path exists
before calling read_text, and route a missing file through the same die-based
error handling used by read. Preserve the existing rewrite and boolean return
behavior for files that exist, including the cmd_set flow.

---

Outside diff comments:
In @.github/workflows/extralit-server.build-docker-images.yml:
- Around line 35-40: Update the run blocks assigning PACKAGE_VERSION from
bump_version.py check in
.github/workflows/extralit-server.build-docker-images.yml lines 35-40,
.github/workflows/extralit-server.yml lines 205-211, and
.github/workflows/extralit.yml lines 176-182: enable strict shell mode with set
-euo pipefail and assert PACKAGE_VERSION is non-empty with test -n
"$PACKAGE_VERSION" before using it.

In @.github/workflows/extralit-server.yml:
- Around line 205-211: Update the “Read package info” step to enable strict
shell mode with set -euo pipefail before invoking bump_version.py, then validate
that PACKAGE_VERSION is non-empty before exporting it so the workflow fails
instead of continuing with an unpinned package.

In @.github/workflows/extralit.yml:
- Around line 176-182: Enable strict shell failure handling at the start of the
“Read package info” run block so a non-zero exit from bump_version.py check
stops the step instead of exporting an empty PACKAGE_VERSION. Preserve the
existing PACKAGE_NAME and environment-file assignments.

---

Nitpick comments:
In @.github/workflows/github-release.yml:
- Around line 30-52: Update the “Wait for PyPI to serve this version” step so
extralit and extralit-server are polled concurrently within a shared 60-attempt
loop, rather than waiting for each package serially. Track each package’s served
state, stop once both are available, and preserve the existing success logging,
30-second polling interval, and failure behavior identifying any package still
unavailable.

In @.github/workflows/release.yml:
- Around line 232-238: Update the tagging command after the commit-or-reuse
logic to handle an existing local tag safely: force-update "$TAG" only when it
already points to the current HEAD, or otherwise check for the tag and reuse it.
Preserve the existing failure behavior for tags pointing to a different commit,
while allowing reruns after a partial release.

In `@CLAUDE.md`:
- Around line 83-88: Clarify the `main` row in the deployment table by
explicitly identifying `extralit-dev/develop` as a Hugging Face Space slug
rather than a Git branch, while preserving the existing deployment target and
the statement that no `develop` branch exists.
- Around line 100-102: Update the versioning instruction in CLAUDE.md to invoke
scripts/bump_version.py through uv run instead of bare python, preserving the
existing set --version X.Y.Z arguments and guidance to avoid manual edits.

In `@extralit/docs/community/release_guide.md`:
- Around line 57-59: Update the release-guide tag cleanup command so remote
deletion and local tag deletion run independently rather than being chained with
&&. Preserve both operations and ensure a missing local v0.7.0 tag cannot make
the overall recovery command appear to fail.

In `@scripts/bump_version.py`:
- Around line 4-6: Update the command examples in the usage documentation for
bump_version.py to invoke the script through uv run instead of python,
preserving the existing check, --expect, and set --version arguments.
- Around line 107-109: Update the return annotation of die to NoReturn, and add
the required typing import so type checkers recognize that every call exits by
raising SystemExit.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 35761991-511a-4437-ace1-56f3cbccbf30

📥 Commits

Reviewing files that changed from the base of the PR and between 36b35a0 and 9bac535.

📒 Files selected for processing (21)
  • .github/actions/download-frontend-artifact/action.yml
  • .github/release.yml
  • .github/workflows/extralit-frontend.build-push-dev.yml
  • .github/workflows/extralit-frontend.yml
  • .github/workflows/extralit-server.build-docker-images.yml
  • .github/workflows/extralit-server.yml
  • .github/workflows/extralit.docs.yml
  • .github/workflows/extralit.yml
  • .github/workflows/github-release.yml
  • .github/workflows/release.yml
  • CLAUDE.md
  • README.md
  • docs/architecture/deployment.md
  • extralit-hf-space
  • extralit/docs/community/adding_language.md
  • extralit/docs/community/contributor.md
  • extralit/docs/community/developer.md
  • extralit/docs/community/index.md
  • extralit/docs/community/release_guide.md
  • extralit/docs/scripts/gen_changelog.py
  • scripts/bump_version.py

Comment thread .github/release.yml
Comment thread .github/workflows/extralit-server.yml
Comment thread .github/workflows/extralit.docs.yml
Comment thread .github/workflows/release.yml
Comment thread .github/workflows/release.yml
Comment thread docs/architecture/deployment.md Outdated
Comment thread extralit/docs/community/contributor.md Outdated
Comment thread extralit/docs/community/release_guide.md
Comment thread extralit/docs/community/release_guide.md
Comment thread scripts/bump_version.py
Addresses the actionable CodeRabbit review on #237.

release.yml's convergence check ran `git checkout "$TAG"` and then executed
scripts/bump_version.py from the tagged commit — code the job hasn't vetted, in
a job that until now also persisted a credential in .git/config. It reads the
three version files straight out of the tag's tree with `git show` instead: no
checkout, no execution, and the checkout/restore dance goes with it. The `plan`
checkout gains `persist-credentials: false`; it only reads.

`release` is a production pointer, not a branch that accumulates work, so `cut`
now moves it under a lease rather than by fast-forward. Once `release` sits off
main's ancestry — a hotfix committed on it, or a rollback to one — a plain
`HEAD:refs/heads/release` is rejected, and `--atomic` fails the whole push after
the version was already stamped and committed. `plan` publishes the `release` sha
it saw and `cut` passes it as `--force-with-lease=refs/heads/release:<sha>`, so
main and the tag stay strictly non-forced and a concurrent move of `release` is
reported instead of clobbered. An empty lease means "must not already exist",
which is the first release. Giving <expect> explicitly also means no prior fetch
is needed for the lease to be meaningful.

release_guide.md tells operators to search the log for three phrases the
workflow never emitted. The workflow now leads with them — including all three
CI-not-green branches (failing, still running, no runs at all) — and keeps the
diagnostic detail as a suffix.

bump_version.py's `write` called read_text without the guard `read` has, and
`cmd_set` writes before it reads, so a missing file made a raw FileNotFoundError
traceback the first output of a release run.

.github/release.yml excluded `dependabot`; GitHub matches the exact login, so it
has to be `dependabot[bot]`.

Docs: CLAUDE.md claimed release notes are generated from PR title prefixes,
contradicting .github/release.yml, which categorises by label. deployment.md
never once said fork PRs get no automatic preview, while both guarded jobs in
build-push-dev.yml skip them — noted at the trigger definition in §3a, with the
manual dispatch escape hatch that does work for forks. deployment.md also still
said the server picks up the frontend artifact "for that branch"; it is always
main. contributor.md suggested `feature/` and `bug/` branch prefixes instead of
`feat/` and `fix/`.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release.yml:
- Around line 59-60: Protect the repository PAT used by the authorize flow
before any workflow code executes: place it in an approval-gated environment and
assign that environment to every job receiving the PAT, including the job
containing the authorization logic and release push. Ensure unapproved
same-repository dispatches cannot access the PAT, while preserving the existing
actor/role authorization checks.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e2733c1b-48d7-4d15-9d86-14735bc1e7bf

📥 Commits

Reviewing files that changed from the base of the PR and between eb2805b and 3bb0994.

📒 Files selected for processing (6)
  • .github/release.yml
  • .github/workflows/release.yml
  • CLAUDE.md
  • docs/architecture/deployment.md
  • extralit/docs/community/contributor.md
  • scripts/bump_version.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • CLAUDE.md
  • extralit/docs/community/contributor.md
  • docs/architecture/deployment.md
  • .github/release.yml
  • scripts/bump_version.py

Comment on lines +59 to +60
# Leads with the phrase release_guide.md tells operators to look for.
echo "::error::Not authorized: '$ACTOR' needs admin or maintain to cut a release (has '$role')."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify whether the release PAT is repository-scoped and whether an approval-gated
# environment protects jobs that receive it.
gh secret list | rg '^GH_ACTIONS_REPOSITORY_DISPATCH\b' || true
gh api --paginate "repos/${GITHUB_REPOSITORY}/environments" \
  --jq '.environments[] | {name, protection_rules}' 
gh api "repos/${GITHUB_REPOSITORY}/actions/permissions"

Repository: Extralit/extralit

Length of output: 355


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- workflow structure and secret propagation ---'
sed -n '1,90p' .github/workflows/release.yml
sed -n '245,305p' .github/workflows/release.yml

printf '%s\n' '--- workflow references ---'
rg -n --fixed-strings \
  -e 'workflow_dispatch' \
  -e 'GH_ACTIONS_REPOSITORY_DISPATCH' \
  -e 'authorize' \
  -e 'cut:' \
  -e 'environment:' \
  -e 'persist-credentials' \
  .github/workflows/release.yml

Repository: Extralit/extralit

Length of output: 7156


🌐 Web query:

site:docs.github.com GitHub Actions workflow_dispatch ref workflow file secrets job environment approval selected branch

💡 Result:

GitHub Actions workflows triggered by the workflow_dispatch event are subject to specific requirements and configuration options regarding branches, secrets, and environment approvals. Triggering and Branches The workflow_dispatch event only triggers a workflow run if the workflow file exists on the default branch of the repository [1][2][3]. While you can manually initiate the workflow from the GitHub UI for any branch, the workflow definition itself—including the trigger configuration—must be present on the default branch [1][3]. Inputs and Environments You can define inputs for manual triggers, including an environment type input, which allows users to select an environment when they trigger the workflow [1][4][3]. - Input Types: Supported types include boolean, choice, number, environment, and string [4][3]. - Approval: To require approval, you must define an environment with configured reviewers [1][2]. A job that references this environment will remain in a pending state until a required reviewer approves the execution [1][5]. Secrets Secrets are managed at the repository or environment level [6][7]. - Access: Secrets are only available to jobs that explicitly reference the environment where those secrets are stored [5][6]. - Security: If an environment requires approval, jobs cannot access that environment's secrets until the necessary approval is granted [5][6]. - Usage: Secrets cannot be used directly in if: conditionals [7]. To conditionally run steps based on a secret, you must first assign the secret to a job-level environment variable [7]. Summary of Workflow Behavior When a workflow is triggered via workflow_dispatch, it receives the provided inputs through the inputs context or the github.event.inputs context [1][3]. If the workflow includes jobs associated with an environment requiring reviewers, those jobs will wait for manual approval before proceeding, providing a gate for sensitive deployments or actions [1][2].

Citations:


Authorization Bypass (CWE-269): Improper Privilege Management

Reachability: Internal · Exploitability: Moderate

Gate GH_ACTIONS_REPOSITORY_DISPATCH before running workflow code.

authorize exposes the repository PAT to workflow code before it checks github.actor. A collaborator with write access can dispatch a same-repository branch that modifies release.yml, read and exfiltrate the PAT, and use it to push protected refs. persist-credentials: false does not prevent workflow steps from reading secrets.

Store the PAT in an approval-gated environment and apply that environment to every job that receives it. Alternatively, move the privileged push into a trusted workflow that executes only the workflow from main.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 59 - 60, Protect the repository
PAT used by the authorize flow before any workflow code executes: place it in an
approval-gated environment and assign that environment to every job receiving
the PAT, including the job containing the authorization logic and release push.
Ensure unapproved same-repository dispatches cannot access the PAT, while
preserving the existing actor/role authorization checks.

@JonnyTran
JonnyTran merged commit 191f14a into develop Aug 6, 2026
5 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