chore(ci): trunk-based CI + one-button release - #237
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📝 WalkthroughWalkthroughThe PR moves repository automation from ChangesRelease and deployment migration
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
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
…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/.
There was a problem hiding this comment.
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 winAdd strict mode to the version lookup.
The command substitution hides a non-zero exit from
bump_version.py, andPACKAGE_VERSIONbecomes 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 winAdd strict mode to the version lookup.
The command substitution hides a non-zero exit from
bump_version.py.PACKAGE_VERSIONthen becomes empty, and the "Test Installing" step installs an unpinned package. Addset -euo pipefailand 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 winThree version lookups discard the exit status of
bump_version.py. Each step assignsPACKAGE_VERSION=$(... bump_version.py check)in arun:body withoutset -euo pipefail. A command substitution does not propagate a non-zero exit, so a script failure or a version-file disagreement yields an emptyPACKAGE_VERSIONand 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: addset -euo pipefailandtest -n "$PACKAGE_VERSION". This site has the largest impact: on thereleasebranch an empty value tags the published imagevand moves:latestonto it..github/workflows/extralit-server.yml#L205-L211: add the same two lines so the "Test Installing" step cannot install an unpinnedextralit-server..github/workflows/extralit.yml#L176-L182: add the same two lines so the "Test Installing" step cannot install an unpinnedextralit.🤖 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 valueClarify the
maindeployment target.The table maps
maintoextralit-dev/develop. The section states that nodevelopbranch 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 valueUse
uv runfor the version command.Line 61 of this same file requires running Python scripts with
uv run <script-name>.py. Line 102 uses barepython. 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>anduv 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 valueDocument the commands with
uv run.The usage examples call
pythondirectly. 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 threeAs per coding guidelines: "Run Python scripts and tools through uv, such as
uv run <script>anduv 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 winAnnotate
dieasNoReturn.
diealways raisesSystemExit, but the annotation says-> None. Type checkers therefore treatread()as able to returnNoneafter adie(...)call, and they cannot prune the error branches.NoReturnmakes 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 valueThe tag deletion command reports failure when no local tag exists.
git tag -d v0.7.0exits 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 valueThe 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: 90covers it. Consider polling both packages in one loop so a slowextralitpublish does not consume the budget forextralit-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 tagfails when the tag already exists locally.
fetch-depth: 0fetches tags. The step comment anticipates a re-run after a partial failure. If a previous run pushed the tag but failed later,planreportsalready_done=trueand skipscut, so the common case is covered. If the tag exists locally but the release is not converged,planalready exits with an error. The remaining gap is a tag created in an earlier run of the same job after a stamped commit, wheregit tagaborts with "already exists" rather than a clear message. Add-fscoped 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
📒 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.ymlCLAUDE.mdREADME.mddocs/architecture/deployment.mdextralit-hf-spaceextralit/docs/community/adding_language.mdextralit/docs/community/contributor.mdextralit/docs/community/developer.mdextralit/docs/community/index.mdextralit/docs/community/release_guide.mdextralit/docs/scripts/gen_changelog.pyscripts/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/`.
There was a problem hiding this comment.
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
📒 Files selected for processing (6)
.github/release.yml.github/workflows/release.ymlCLAUDE.mddocs/architecture/deployment.mdextralit/docs/community/contributor.mdscripts/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
| # 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')." |
There was a problem hiding this comment.
🔒 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.ymlRepository: 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:
- 1: https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow
- 2: https://docs.github.com/actions/using-workflows/triggering-a-workflow
- 3: https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions
- 4: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax
- 5: https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments
- 6: https://docs.github.com/en/actions/concepts/security/secrets
- 7: https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets
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.
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.
developNothing here changes behaviour until you flip the trunk. The new triggers name only
main,release,v*tags andpull_request— merging this todevelopfires no workflow, moves no image, and deploys nothing.extralit/public-demostays frozen on its current image until someone runsrelease.ymldeliberately.The model
main(trunk, default)extralitdev/*:main+:latest, amd64extralit-dev/developrelease(long-lived)extralit/*:vX.Y.Z+:latest, amd64+arm64extralit/public-demovX.Y.Ztagextralit,extralit-serverextralitdev/*:pr-N, amd64extralit-dev/pr-Nreleaseis always a tagged point onmain's history: the cut pushes one stamp commit tomain,release, and the tag in a single atomic push, so all three agree. Rollback is re-pointingreleaseat an older tag.What changed
Releases.
scripts/bump_version.pyis now the single owner of the version, which lived hand-synced in three files.release.ymlcuts a release:dry_rundefaults to true.planis 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=trueto override), and detects a converged re-run so recovery is safe.github-release.ymlwaits until PyPI actually serves both packages before creating the GitHub Release, so a release is never announced before it's installable.Triggers.
pushnarrows tomain+release(+v*tags on the publishing workflows) andpull_requestis added. Today afeat/**push runs CI but a PR from a fork runs nothing — that inverts.develop,feat/**andreleases/**are retired;releases/**was broken anyway (a push there dispatchedbranch=releases/v0.6.1, which fell throughresolve-envand created a PR-preview Space).PRs run tests and nothing else.
build_docker_imagescollapses togithub.ref_type == 'branch' && github.event_name != 'pull_request'. PR preview images already have an owner —extralit-frontend.build-push-dev.yml, gated onready_for_reviewand the only caller that passespr_number. Building here too would double-dispatch on that event and, with nopr_number, moveextralitdev/*:latestfrom unreviewed code. Theref_typehalf stops a tag push building a stray dev image and dispatchingbranch=vX.Y.Z, whichresolve-envwould turn into a junk preview Space.The
draft == falseguard is gone from both build jobs:null == falseis 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_releasefired on every push tomain; under trunk that's a PyPI publish per merge. It's nowstartsWith(github.ref, 'refs/tags/v'). Itsneedsonbuild_docker_imagesis 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.ymlderived the image tag by grepping_version.py, so amainbuild silently overwrote an existingv0.6.1image whenever the file wasn't bumped — and no workflow ever created a git tag. All threegrep '__version__' | cutincantations now callbump_version.py check.Concurrency moves from
github.shatogithub.ref. The release's atomic push landsmainandreleaseat the same SHA, so a sha-keyed group made those runs cancel each other at random — if thereleaserun lost that race the production build was silently killed.Docs are retargeted:
docs/architecture/deployment.md(which also had ~10 links toextralit-pr-preview.yml, a file that does not exist, and still claimed the server compiles the frontend from source withBASE_URL=@@baseUrl@@— untrue since the Nuxt 4 migration),release_guide.md,contributor.md,developer.md,CLAUDE.md, andgen_changelog.py(which read the published changelog fromdevelopregardless of which ref built the docs).mikealiases follow the model:main→latest+ default,vX.Y.Z→X.Y+stable.Verification
actionlintclean on every touched workflow. The only remaining findings are pre-existing and in files this PR doesn't touch (steps.metaundefined inbuild-docker-images.yml, stale action versions in the teardown/stale workflows).shellcheckisn't available to actionlint here, so every non-trivialrun:body was extracted from the YAML and executed underbashagainst stubbed env: 26 cases acrossrelease.ymlcovering version validation (including0.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-spaceresolve-envrewrite in [Snyk] Security upgrade nuxt from 2.18.1 to 3.12.4 #7.python3 scripts/bump_version.py check→0.6.1across all three files; round-trips throughset/check --expect.Before merging / after merging
Ordered — the monorepo flip has to happen after the Space repo can accept the new payload.
main(prefer a merge commit; it carries 13developcommits). Nothing deploys — that repo is dispatch-driven only.Extralit/extralit-hf-space:main→production,develop→staging. 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 emptyvars.*.gh repo edit Extralit/extralit-hf-space --default-branch main, then delete itsdevelop.develop, then re-point the submodule at hf-spacemain(it currently points atchore/trunk-based-deploys' head,35086a6, so this PR is reviewable as a whole).gh pr list --base develop --json number -q '.[].number' | xargs -I{} gh pr edit {} --base maingit push origin origin/develop:main. This is a fast-forward —git rev-list --count origin/develop..origin/mainis 0, somainis a strict ancestor. Firesextralit-server.ymlonmain→ dev images →extralit-dev/develop. Production untouched.main, deletedevelop, add branch protection (require PR), and grant the release PAT a protection bypass so the atomic push can land.gh workflow run release.yml -f version=0.7.0, read the plan, then-f dry_run=false.developmigration alias from the hf-spaceresolve-envonce step 6 is verified.Don't mark
extralit-server/extralit-frontend/extralitas required checks. All three keep apaths: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)
permissions: id-token: writeis already declared in both publishing workflows but unused.uv publish --trusted-publishing alwayswould dropAR_PYPI_API_TOKENentirely. Needs a one-time publisher registration on PyPI per project, so this PR keeps the token path..github/actions/generate-credentialsandslack-post-credentialsare referenced by no workflow, andextralit-frontend.teardown{,-all}-pr-environment{,s}.ymltarget a deadargilla-ciGCP project with their real triggers commented out.steps.meta.outputs.labelsinbuild-docker-images.ymlreferences a step that doesn't exist and silently resolves to empty.Summary by CodeRabbit
New Features
Documentation