Skip to content

Fix CLI releases to use pushed tags - #1362

Merged
Nikhil (shadowfax92) merged 5 commits into
mainfrom
fix/cli-tag-release
Jun 24, 2026
Merged

Nikhil (shadowfax92) merged 5 commits into
mainfrom
fix/cli-tag-release

Conversation

@shadowfax92

Copy link
Copy Markdown
Contributor

Summary

  • trigger CLI release automation from pushed cli/vX.Y.Z tags and derive X.Y.Z from the tag
  • validate strict annotated tags, default-branch reachability, incrementing production versions, and release-note baselines across new plus legacy CLI tags
  • update CDN/GitHub/npm release metadata and docs so the git tag is the release source of truth

Verification

  • bun test scripts/build/cli/release-policy.test.ts scripts/build/cli/upload.test.ts
  • bun run ./scripts/run-bun-test.ts ./scripts/build
  • make test in packages/browseros-agent/apps/cli (integration tests skipped because the local server was not reachable at http://127.0.0.1:9105)
  • make vet in packages/browseros-agent/apps/cli
  • make release VERSION=0.2.3 POSTHOG_API_KEY=test in packages/browseros-agent/apps/cli
  • bunx @biomejs/biome check scripts/build/cli/release-policy.ts scripts/build/cli/release-policy.test.ts scripts/build/cli/upload.ts scripts/build/cli/upload.test.ts apps/cli/npm/scripts/postinstall.js

Draft blocker

  • bun run check fails in unrelated apps/server typecheck errors, including src/agent/format-message.ts implicit any parameters and ResolvedLLMConfig property errors in src/api/services/chat-service.ts / src/lib/clients/llm/provider.ts.

Expected release command remains:

git tag -a cli/v0.2.3 -m "browseros-cli v0.2.3"
git push origin cli/v0.2.3

@shadowfax92

Copy link
Copy Markdown
Contributor Author

Greptile (@greptileai) review

@shadowfax92

Copy link
Copy Markdown
Contributor Author

Claude (@claude) review

@claude

claude Bot commented Jun 24, 2026 •

Copy link
Copy Markdown

Claude finished Nikhil (@shadowfax92)'s task in 3m 16s —— View job


Code Review

  • Gather context and read changed files
  • Review workflow changes (.github/workflows/release-cli.yml)
  • Review release policy module (release-policy.ts / release-policy.test.ts)
  • Review upload changes and postinstall script
  • Review README documentation
  • Compile and post final review

Overall this is a well-designed PR. Moving from manual workflow_dispatch with a typed version to tag-triggered releases is the right pattern — the tag becomes the source of truth, and release-policy.ts enforces the safety gates cleanly. Good test coverage on all the pure logic. A few things to fix before merging:

Bug: heredoc indentation will break code fence rendering in release notes

.github/workflows/release-cli.yml lines 110–134

The heredoc uses <<'EOF' (not <<-'EOF'), so the 10 spaces of YAML indentation are included verbatim in every line written to /tmp/release-notes.md. CommonMark (and GitHub Flavored Markdown) only recognize a fenced code block when the opening ``` has at most 3 spaces of leading indentation. With 10 spaces, the triple-backtick fences render as literal text instead of code blocks — users will see ```bash instead of formatted code.

Fix: use <<-'EOF' and indent the heredoc body with tabs (not spaces), or strip the leading whitespace explicitly.

        run: |
          cat >> /tmp/release-notes.md <<-'EOF'

	## Install `browseros-cli`

	### npm / npx

	```bash
	npx browseros-cli --help
	npm install -g browseros-cli
	```
          EOF

Fix this →


Dead code in selectPreviousCliReleaseTag

release-policy.ts lines 118–125

const selected = candidates[0]
if (
  compareVersionTuple(parseReleaseVersion(selected.version), current) >= 0
) {
  throw new Error(
    `Previous release tag ${selected.tag} is not before ${currentVersion}`,
  )
}

candidates is already filtered at line 103 to only include tags where compareReleaseVersions(version, currentVersion) < 0, so selected.version is always strictly less than currentVersion. This check can never trigger and the compareVersionTuple import alias exists solely for it. Remove the check and the local current variable to avoid confusing future readers.

Fix this →


writeGithubOutputs silently no-ops when GITHUB_OUTPUT is unset

release-policy.ts lines 301–317

If the GITHUB_OUTPUT env var is missing (e.g. someone runs the script locally without setting it, or a future CI change omits it), the function returns silently. Downstream steps then expand ${{ steps.release.outputs.version }} to an empty string, and make release VERSION= would fail in an obscure way rather than at the policy check. At minimum, a console.warn when not in a GitHub Actions context would help diagnose this class of failure.


Undocumented side effect in ensureTagReachableFromDefaultBranch

release-policy.ts lines 220–225

The function issues a git fetch origin which modifies the local repo state. This is necessary in CI (the workflow checks out with --depth 0 but the remote branch ref needs to exist for merge-base --is-ancestor), but ensure* naming implies a pure assertion. A short comment or a renamed helper (e.g. fetchAndEnsureTagReachable) would prevent the next person from accidentally calling this in a context where fetching is undesirable or disallowed.


Minor: duplicate version comparison logic

release-policy.ts lines 56–68 and 178–186

compareReleaseVersions and compareVersionTuple implement the same loop over a 3-tuple. The string-input variant parses first; the tuple variant operates directly. They're both small, but the duplication makes version-comparison logic harder to update consistently. Consider inlining one in terms of the other, or replacing compareVersionTuple with a trivially-wrapped call to compareReleaseVersions.


Positives

  • Repair-rerun gate (assertIncrementingRelease with tag === latestTag) is thoughtful; the test at line 80 proving that old-format latestTag doesn't grant a repair pass is exactly the right invariant to pin.
  • selectPreviousCliReleaseTag correctly handles the legacy browseros-cli-v* tag format so changelog generation stays continuous across the tag-rename boundary.
  • Annotated-tag enforcement prevents accidental lightweight-tag releases.
  • --verify-tag on gh release create is a nice belt-and-suspenders check given the in-process validation.
  • Test coverage on all the pure functions is solid, and the temp-git-repo integration test for reachability is the right way to test that gate.

@github-actions

github-actions Bot commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

✅ Tests passed — 1415/1419

Suite Passed Failed Skipped
✅ agent 271/271 0 0
✅ build 37/37 0 0
⚠️ claw-app 0/0 0 0
⚠️ claw-server 0/0 0 0
✅ eval 91/91 0 0
✅ server-agent 301/301 0 0
✅ server-api 143/143 0 0
✅ server-browser 10/10 0 0
✅ server-integration 10/10 0 0
✅ server-lib 256/257 0 1
✅ server-root 47/50 0 3
✅ server-tools 249/249 0 0

View workflow run

@greptile-apps

greptile-apps Bot commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR migrates the CLI release workflow from a manually-triggered workflow_dispatch (where the version was typed in) to an automated push-on-cli/vX.Y.Z tag trigger, making the annotated git tag the single source of truth for version and release identity.

  • New release-policy.ts gate: validates that the pushed tag is annotated, that its target commit is reachable from the default branch, and that the version strictly increments beyond CDN production latest (with a same-tag repair-rerun carve-out) before any build, upload, or publish step runs.
  • Tag-format migration: browseros-cli-vX.Y.Z → cli/vX.Y.Z across the GitHub release name, CDN manifest tag field, and postinstall.js download URL (slash escaped via encodeURIComponent); legacy tags are still recognised for changelog baseline selection.
  • Workflow clean-up: removes the inline tag-creation block (tag now pre-exists as the trigger), replaces HEAD with the explicit tag ref in the git log range, and adds an idempotent edit/upload path for repair reruns.

Confidence Score: 5/5

Safe to merge — the tag-driven trigger replaces a manual version input with a well-validated, annotated-tag gate that checks branch reachability and monotonic versioning before any build or publish step runs.

The validation logic in release-policy.ts is thorough and well-tested, the legacy tag format is handled for changelog baseline selection, the repair-rerun path is explicitly covered, and the npm postinstall URL change (encodeURIComponent for the slash in cli/vX.Y.Z) is correct. The two issues noted in previous review threads (dead code guard and unquoted dist glob) are the only known gaps and neither blocks correct operation of the release.

The unquoted ${CLI_DIST}/* glob in the Create GitHub release step of release-cli.yml (previously flagged) is worth addressing before the first production run to avoid silent failures if the dist directory is unexpectedly empty.

Important Files Changed

Filename Overview
.github/workflows/release-cli.yml Reworked from workflow_dispatch to push-on-tag; validation gate added; git log range fixed to use explicit tag instead of HEAD. Previously flagged unquoted glob in gh release upload/create lines is still present.
packages/browseros-agent/scripts/build/cli/release-policy.ts New validation module; well-structured with good error messages. Dead code in selectPreviousCliReleaseTag (current variable + unreachable compareVersionTuple guard) was already flagged in the previous review thread.
packages/browseros-agent/scripts/build/cli/release-policy.test.ts Thorough test coverage including git-repo integration tests for annotated-tag and reachability checks; repair-rerun edge cases explicitly tested.
packages/browseros-agent/apps/cli/npm/scripts/postinstall.js Correctly uses encodeURIComponent to escape the slash in cli/vX.Y.Z so the GitHub download URL path is unambiguous; redirect-following logic is unaffected since redirect targets are asset-scoped URLs.
packages/browseros-agent/scripts/build/cli/upload.ts Single-line change: CDN manifest tag field updated from browseros-cli-vX.Y.Z to cli/vX.Y.Z, consistent with the new tag format.
packages/browseros-agent/scripts/build/cli/upload.test.ts Test expectation updated to match new cli/vX.Y.Z tag format in the manifest snapshot.
packages/browseros-agent/apps/cli/README.md Added release-flow section documenting the new tag-driven process and version inspection commands.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Developer: git tag -a cli/vX.Y.Z] --> B[git push origin cli/vX.Y.Z]
    B --> C[GitHub Actions: push trigger on cli/v*]
    C --> D[Checkout repo - fetch-depth 0]
    D --> E[Validate release tag\nrelease-policy.ts validate]

    E --> E1{Tag format\ncli/vX.Y.Z?}
    E1 -- No --> FAIL1[Fail: bad tag format]
    E1 -- Yes --> E2{Annotated tag?}
    E2 -- No --> FAIL2[Fail: not annotated]
    E2 -- Yes --> E3{Commit reachable\nfrom default branch?}
    E3 -- No --> FAIL3[Fail: not on main]
    E3 -- Yes --> E4{Version > CDN latest\nor same-tag repair?}
    E4 -- No --> FAIL4[Fail: not incrementing]
    E4 -- Yes --> E5[Emit outputs: version, tag,\nprevious_tag, target_commit]

    E5 --> F[Build all platforms\nmake release VERSION=X.Y.Z]
    F --> G[Upload to CDN\nwrites manifest tag=cli/vX.Y.Z]
    G --> H[Generate release notes\ngit log PREV_TAG..TAG]
    H --> I{GitHub release\nalready exists?}
    I -- Yes repair rerun --> J[gh release edit + upload --clobber]
    I -- No --> K[gh release create --verify-tag]
    J --> L[npm publish\npostinstall downloads from\ngithub.com/.../cli%2FvX.Y.Z/...]
    K --> L
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Developer: git tag -a cli/vX.Y.Z] --> B[git push origin cli/vX.Y.Z]
    B --> C[GitHub Actions: push trigger on cli/v*]
    C --> D[Checkout repo - fetch-depth 0]
    D --> E[Validate release tag\nrelease-policy.ts validate]

    E --> E1{Tag format\ncli/vX.Y.Z?}
    E1 -- No --> FAIL1[Fail: bad tag format]
    E1 -- Yes --> E2{Annotated tag?}
    E2 -- No --> FAIL2[Fail: not annotated]
    E2 -- Yes --> E3{Commit reachable\nfrom default branch?}
    E3 -- No --> FAIL3[Fail: not on main]
    E3 -- Yes --> E4{Version > CDN latest\nor same-tag repair?}
    E4 -- No --> FAIL4[Fail: not incrementing]
    E4 -- Yes --> E5[Emit outputs: version, tag,\nprevious_tag, target_commit]

    E5 --> F[Build all platforms\nmake release VERSION=X.Y.Z]
    F --> G[Upload to CDN\nwrites manifest tag=cli/vX.Y.Z]
    G --> H[Generate release notes\ngit log PREV_TAG..TAG]
    H --> I{GitHub release\nalready exists?}
    I -- Yes repair rerun --> J[gh release edit + upload --clobber]
    I -- No --> K[gh release create --verify-tag]
    J --> L[npm publish\npostinstall downloads from\ngithub.com/.../cli%2FvX.Y.Z/...]
    K --> L
Loading

Reviews (2): Last reviewed commit: "test(cli): cover release tag safety gate..." | Re-trigger Greptile

Comment on lines +94 to +126
export function selectPreviousCliReleaseTag(
tags: string[],
currentVersion: string,
): string {
const current = parseReleaseVersion(currentVersion)
const candidates = tags
.map(parseKnownCliTag)
.filter((tag): tag is ParsedCliReleaseTag => tag !== null)
.filter(
({ version }) => compareReleaseVersions(version, currentVersion) < 0,
)
.sort((a, b) => {
const versionOrder = compareReleaseVersions(b.version, a.version)
if (versionOrder !== 0) {
return versionOrder
}
return tagPriority(b.tag) - tagPriority(a.tag)
})

if (candidates.length === 0) {
return ''
}

const selected = candidates[0]
if (
compareVersionTuple(parseReleaseVersion(selected.version), current) >= 0
) {
throw new Error(
`Previous release tag ${selected.tag} is not before ${currentVersion}`,
)
}
return selected.tag
}

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.

P2 Unreachable defensive check and unused variable

The current variable (line 98) and the compareVersionTuple guard at lines 117–124 can never fire. The .filter at line 103 already guarantees every candidate satisfies compareReleaseVersions(version, currentVersion) < 0, so selected.version is always strictly less than currentVersion. The final check is logically dead and the current binding is only referenced there.

Rule Used: Remove unused/dead code rather than leaving it in ... (source)

Learned From
browseros-ai/BrowserOS-agent#126

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/browseros-agent/scripts/build/cli/release-policy.ts
Line: 94-126

Comment:
**Unreachable defensive check and unused variable**

The `current` variable (line 98) and the `compareVersionTuple` guard at lines 117–124 can never fire. The `.filter` at line 103 already guarantees every candidate satisfies `compareReleaseVersions(version, currentVersion) < 0`, so `selected.version` is always strictly less than `currentVersion`. The final check is logically dead and the `current` binding is only referenced there.

**Rule Used:** Remove unused/dead code rather than leaving it in ... ([source](https://app.greptile.com/browseros-org-2/-/custom-context?memory=9b045db4-2630-428c-95b7-ccf048d34547))

**Learned From**
[browseros-ai/BrowserOS-agent#126](https://github.com/browseros-ai/BrowserOS-agent/pull/126)

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +145 to +156
if gh release view "$TAG" >/dev/null 2>&1; then
gh release edit "$TAG" \
--title "BrowserOS CLI - v${VERSION}" \
--notes-file /tmp/release-notes.md
gh release upload "$TAG" ${CLI_DIST}/* --clobber
else
gh release create "$TAG" \
--verify-tag \
--title "BrowserOS CLI - v${VERSION}" \
--notes-file /tmp/release-notes.md \
${CLI_DIST}/*
fi

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.

P2 Unquoted glob expands in shell before gh sees it

Both the gh release upload and gh release create paths pass ${CLI_DIST}/* unquoted. Shell glob expansion happens before the command runs, so if the working directory is ever not the workspace root or the dist path is empty the argument becomes the literal string packages/browseros-agent/apps/cli/dist/* (unexpanded) and gh receives a non-existent path. The gh release create path has the same pattern. Quoting the glob sidesteps both failure modes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/release-cli.yml
Line: 145-156

Comment:
**Unquoted glob expands in shell before `gh` sees it**

Both the `gh release upload` and `gh release create` paths pass `${CLI_DIST}/*` unquoted. Shell glob expansion happens before the command runs, so if the working directory is ever not the workspace root or the dist path is empty the argument becomes the literal string `packages/browseros-agent/apps/cli/dist/*` (unexpanded) and `gh` receives a non-existent path. The `gh release create` path has the same pattern. Quoting the glob sidesteps both failure modes.

How can I resolve this? If you propose a fix, please make it concise.

@shadowfax92
Nikhil (shadowfax92) marked this pull request as ready for review June 24, 2026 19:23
@shadowfax92
Nikhil (shadowfax92) merged commit 4f09309 into main Jun 24, 2026
38 of 39 checks passed
Vasilev Dmitrii (gHashTag) pushed a commit to gHashTag/BrowserOS that referenced this pull request Sep 4, 2026
`wait` means "not judged yet". The sweep deliberately re-reads wait rows so a
torn verdict gets another look, and its own comment states the limit of that:
"an unchanged transcript yields the same wait". The transcript of a FINISHED
bee never changes, so re-reading is not re-judging - the same input gives the
same answer every round, while the policy reports "N of M criteria judged SO
FAR" and there is no later.

Measured 2026-09-04: browseros-ai#1361 and browseros-ai#1362 sat in wait for hours, holding their
boundaries and counted in `claimed` against every candidate touching them.

Six hours rather than the send-back's one. A wait CAN resolve by itself - a
transcript merely slow to flush parses on a later sweep - so the clock must be
long enough that only a genuinely frozen one is released.

`escalate` is deliberately untouched. It asks for a person, and a timer is not
a person.

One existing assertion covered escalate, wait and null together and now fails,
correctly: the behaviour it described was changed on purpose. It was narrowed to
escalate rather than deleted, because the rule it still states is the one that
matters most.

Tests: 6 new, 13 in the file, 147 in the queen suite, 0 failures.

Closes gHashTag/trios#1408
Vasilev Dmitrii (gHashTag) added a commit to gHashTag/BrowserOS that referenced this pull request Sep 4, 2026
A wait verdict on a FINISHED dispatch is re-read every round and can
never change, because the transcript it would be judged from is
immutable: the same 'N of M criteria judged so far' for ever, holding
the dispatch's boundary paths against every candidate that touches
them until the 48-hour window expires. Measured 2026-09-04: browseros-ai#1361
(finished after 289 s) and browseros-ai#1362 (after 1870 s) both sat in wait hours
later.

tools/frozen-wait-report.mjs names the condition and writes nothing
(FR-001). Rows arrive as a caller-supplied JSON file, never a live
connection, and an unreadable file is an error rather than an empty
report (FR-002). Fresh, frozen and unreadable are three separate
outcomes with three separate counts (FR-003); the fresh/frozen
threshold is the named constant FRESH_WINDOW_MS and every run prints
it (FR-004). Node standard library only (FR-005).

--selftest builds a fixture with one fresh row, one frozen row, one
fully-judged row (not listed - it resolves on the next sweep) and one
unreadable transcript, asserts the four outcomes, and guards that an
unreadable transcript is never counted as zero criteria judged;
FROZEN_WAIT_DEFECT=unreadable-as-zero removes that distinction and the
run fails on the guard, exit 1. classifyWaitRow is exported for reuse.

Co-authored-by: Trinity Bee <bee@trinity.local>
Vasilev Dmitrii (gHashTag) added a commit to gHashTag/BrowserOS that referenced this pull request Sep 4, 2026
…, and close-done kept its own copy of the rule (#360)

`tri why` warned ahead: all nine remaining accepted branches conflict, so nothing
can land, close-done will close nothing, and the pipeline stops as soon as those
boundaries are all that is left. Four of the nine were finished work.

THE ROUTE NO COMPARISON OF BYTES CAN TAKE. `isLanded` had four routes -
ancestry, an identical merged tree, a patch-id match, and a hand-applied change.
Every one of them compares CONTENT. They are all blind to the thing this loop
does constantly: when a bee's branch goes stale, I re-cut its change against the
current base and squash-merge THAT. The carry is a new commit with a new tree and
a new patch-id, so nothing content-shaped connects it back, and the bee's
original branch becomes permanent debt - re-offered every round, conflicting
every round, holding its boundary fenced for ever.

So read the message. L1 of this repository is "no code merged without
`Closes #N`", which makes the message a load-bearing record and not a courtesy:

  browseros-ai#1310  landed as PR #330
  browseros-ai#1308  landed as PR #331
  browseros-ai#1362  `Closes browseros-ai#1362` in a base commit
  browseros-ai#1421  carried by a commit that says "Carries the browseros-ai#1421 work it belongs with"

Nine conflicting branches became six. The matching is done in JavaScript rather
than in git's regex, because two rounds ago a BRE read as a JavaScript regex
convicted a bee - the dialect belongs somewhere it is known.

AND THE TIGHTENING BROKE THE CASE IT WAS WRITTEN FOR. I required `(#N)` to end
the line, to reject "unlike (browseros-ai#1421), this does X". One minute later browseros-ai#1310 stopped
being recognised: a squash subject here reads
`feat(queen): explain idle paid slots (browseros-ai#1310) (#330)` - the issue first, then the
pull request. A trailing CHAIN of references is a subject; a parenthesis in the
middle of a sentence is not.

CLOSE-DONE KEPT ITS OWN COPY OF THE RULE. It had the tree test and nothing else,
for weeks, while `land.mjs` grew four more routes it never learned. A rule
transcribed twice is two rules that agree until somebody edits one - which is L2
of this repository, and it had happened here in the file that decides whether an
issue may be closed. It asks `land.mjs` now.

What remains is real debt and is reported as such: browseros-ai#1387, browseros-ai#1302 and browseros-ai#1303 carry
880 insertions of finished work outside the base, all three with their issues
already CLOSED - the inverse of the false statement close-done exists to prevent.
A conflict is still reported for a person and never resolved by guessing.

selftest 144 pass 0 fail.

Co-authored-by: Dmitrii Vasilev <trackgmedernj@hotmail.com>
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