Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a564c25
ci: sync crate versions with the release tag
lollipopkit Aug 10, 2026
e32ca45
feat: align command surface with the latest exe.dev docs
lollipopkit Aug 10, 2026
61304ef
fix: harden release tag handling and correct token-exposure guidance
lollipopkit Aug 10, 2026
c281a48
fix: validate release inputs and widen the dangerous-command guard
lollipopkit Aug 10, 2026
6b6a1b9
ci: stop persisting the checkout token in the release build
lollipopkit Aug 10, 2026
7d83306
fix: bound remote SSH steps and close secret and readiness gaps
lollipopkit Aug 10, 2026
a28655b
fix: make release publishing and version rewriting fail closed
lollipopkit Aug 10, 2026
1eddfcd
fix: make bootstrap state and release publishing failure-safe
lollipopkit Aug 10, 2026
a034aa5
fix: confine state paths and reject empty or unpinned release inputs
lollipopkit Aug 12, 2026
86a371f
fix: bind existing-mode kubectl to K3S_URL and tighten command classi…
lollipopkit Aug 12, 2026
97b0ed2
fix: reconcile node taints and close remaining state and release gaps
lollipopkit Aug 12, 2026
19e9d71
fix: close release-script write paths and merge wrapped listings
lollipopkit Aug 12, 2026
ce5aaac
docs: tell the skill which commands ask for confirmation
lollipopkit Aug 12, 2026
caff018
docs: point SSH triage at the reported destination
lollipopkit Aug 12, 2026
f1bdea7
fix: validate cluster targets earlier and stop clobbering concurrent …
lollipopkit Aug 12, 2026
49b0959
fix: validate reported destinations and make state handling durable
lollipopkit Aug 13, 2026
74073e5
fix: reserve generated labels and stop guessing inventory from prose
lollipopkit Aug 13, 2026
c4e1d13
fix: repair the k3s pid check and the state digest
lollipopkit Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 167 additions & 26 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,67 @@ env:
CARGO_TERM_COLOR: always

jobs:
resolve:
name: resolve release commit
runs-on: ubuntu-24.04
outputs:
tag: ${{ steps.meta.outputs.tag }}
sha: ${{ steps.meta.outputs.sha }}

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

# Resolved once, here, so every matrix build and the publish step agree on a
# single immutable commit. Resolving the tag independently per job would let a
# tag moved mid-run produce archives from more than one commit.
- name: Resolve the tag to a commit
id: meta
shell: bash
env:
GH_TOKEN: ${{ github.token }}
INPUT_TAG_NAME: ${{ inputs.tag_name }}
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
tag="${INPUT_TAG_NAME}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Build Deployment | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
Manual dispatch does not reject an invalid tag name at the resolve boundary. Any existing non-semver tag (or a tag object whose peeled object is not a commit) is accepted and emitted as `needs.resolve.outputs.tag/sha`; the workflow only discovers a semver failure later in each matrix build when set-version runs, wasting builds and violating the requirement that invalid dispatch tags be rejected before build. There is also no explicit validation that the fetched object type is commit after peeling.

# Validated before it is used, not after: the tag goes into an API path
# below, where `../../owner/repo/git/ref/tags/v1` would traverse to
# another repository and resolve a SHA there.
scripts/release/check-version.sh "${tag}" > /dev/null
# One read of the ref, then one of the tag object: asking twice can
# pair a SHA from before a tag move with a type from after it.
ref_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}")"
sha="$(printf '%s' "${ref_json}" | jq -r '.object.sha')"
type="$(printf '%s' "${ref_json}" | jq -r '.object.type')"
# An annotated tag points at a tag object, not the commit it names,
# and a tag object can name another one. Bounded so a cycle cannot
# spin here.
for _ in 1 2 3 4 5; do
[[ "${type}" == "tag" ]] || break
tag_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}")"
sha="$(printf '%s' "${tag_json}" | jq -r '.object.sha')"
type="$(printf '%s' "${tag_json}" | jq -r '.object.type')"
done
if [[ "${type}" != "commit" ]]; then
echo "tag ${tag} does not resolve to a commit (got ${type})" >&2
exit 1
fi
else
tag="${GITHUB_REF_NAME}"
sha="${GITHUB_SHA}"
fi
# The push path only filters `v*`, so a pushed `v1.2` is rejected here
# rather than in each matrix build, after four checkouts and toolchains.
scripts/release/check-version.sh "${tag}" > /dev/null
echo "tag=${tag}" >> "${GITHUB_OUTPUT}"
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"

build:
name: build ${{ matrix.platform }}
needs: resolve
runs-on: ${{ matrix.os }}

strategy:
Expand All @@ -41,17 +100,24 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref }}
# The commit the resolve job pinned, never a ref name: a ref is resolved
# again at checkout time, so a tag moved between the trigger and this step
# would build a commit the release was never requested for.
ref: ${{ needs.resolve.outputs.sha }}
# This job runs no git operations after checkout, and the build compiles
# third-party crates, so leaving the token in .git/config would only widen
# what a compromised dependency can reach.
persist-credentials: false

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Build Deployment | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact Rust and musl-tools versions used on any particular run are not observable from the workflow alone, so the precise binary delta cannot be predicted; however, both inputs are demonstrably resolved from moving sources rather than pinned versions.
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
The build is not reproducible despite using `cargo build --locked`: the workflow installs the moving `stable` Rust channel and the unversioned `musl-tools` package from the current Ubuntu repositories. Re-running the same resolved commit after either toolchain or system package updates can produce different binaries (or fail), so `--locked` only fixes Cargo dependency resolution and does not satisfy the stated reproducible locked-build obligation.

with:
targets: ${{ matrix.target }}

- name: Cache Cargo
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: ${{ matrix.target }}

Expand All @@ -72,21 +138,31 @@ jobs:
;;
esac

# Must run before the build: clap bakes CARGO_PKG_VERSION in at compile time, so a
# binary built ahead of this step reports whatever the crates were last set to
# rather than the tag it ships under.
- name: Sync crate versions with release tag
shell: bash
env:
# Passed through the environment rather than interpolated into the script,
# so a crafted dispatch input cannot inject shell commands.
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
scripts/release/set-version.sh "${RELEASE_TAG}"

- name: Build optimized release binaries
run: cargo build --profile dist --locked --target ${{ matrix.target }} -p exedev-ctl -p exedev-k8s

- name: Package release archive
id: package
shell: bash
env:
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail

tag="${GITHUB_REF_NAME}"
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
tag="${{ inputs.tag_name }}"
fi

archive="exedev-clis-${tag}-${{ matrix.platform }}.tar.gz"
archive="exedev-clis-${RELEASE_TAG}-${{ matrix.platform }}.tar.gz"
mkdir -p package dist

cp "target/${{ matrix.target }}/dist/exedev-ctl" "package/exedev-ctl"
Expand All @@ -102,41 +178,106 @@ jobs:
echo "archive=${archive}" >> "${GITHUB_OUTPUT}"

- name: Upload archive artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-${{ matrix.platform }}
path: dist/${{ steps.package.outputs.archive }}
if-no-files-found: error

# Separate from publish so the write-capable token is not in scope while this
# runs: the job that holds contents: write grants it to every one of its steps.
verify:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: unknown
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ Repository-level tag protection or an external serialization mechanism is not visible in this workflow; either could prevent an authorized tag move during the interval.
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
The pre-publish verification does not actually prevent publishing a release for a tag that moves after the check. If an authorized actor moves `RELEASE_TAG` from `BUILT_SHA` to another commit after `verify` reads the ref but before `softprops/action-gh-release` creates/updates the release, the publish step attaches the release to the moved tag; `confirm` only fails afterward, leaving an already-published mismatched release. This is proven false only if the tag is guaranteed immutable for the entire verify-to-publish interval or publication is otherwise serialized with ref updates.

name: verify release commit
needs: [resolve, build]
runs-on: ubuntu-24.04

steps:
# The archives were built from one commit; the release is about to be
# attached to a tag name. If the tag moved in between, publishing would ship
# binaries that do not match the source the tag now points at.
- name: Verify the tag still points at the built commit

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Correctness | 🟡 Medium

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
The publish-time moving-tag check is not an atomic safeguard: it reads the tag target, then separately invokes `softprops/action-gh-release` with only `tag_name`. If an authorized actor moves the tag after the `gh api` check but before or during the release action, the action can create/update the release for the new target while attaching archives built from `BUILT_SHA`. Thus the workflow can publish artifacts that do not match the tag despite the verification step. This would be false only if the repository prevents tag updates for the entire check-to-action interval or the release action/API pins the release target to `BUILT_SHA` (neither is expressed here).

shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
BUILT_SHA: ${{ needs.resolve.outputs.sha }}
run: |
set -euo pipefail
# Single read of the ref, as in resolve: two requests can pair a SHA
# from one side of a tag move with a type from the other.
ref_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}")"
sha="$(printf '%s' "${ref_json}" | jq -r '.object.sha')"
type="$(printf '%s' "${ref_json}" | jq -r '.object.type')"
# Peeled the same way as resolve, including a tag naming another tag.
for _ in 1 2 3 4 5; do
[[ "${type}" == "tag" ]] || break
tag_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}")"
sha="$(printf '%s' "${tag_json}" | jq -r '.object.sha')"
type="$(printf '%s' "${tag_json}" | jq -r '.object.type')"
done
if [[ "${sha}" != "${BUILT_SHA}" ]]; then
echo "tag ${RELEASE_TAG} now points at ${sha}, but these archives were built from ${BUILT_SHA}" >&2
exit 1
fi

publish:
name: publish release
needs: build
needs: [resolve, verify]
runs-on: ubuntu-24.04
permissions:
contents: write

steps:
- name: Download release archives
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: release-*
path: dist
merge-multiple: true

- name: Resolve release tag
id: meta
shell: bash
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
tag="${{ inputs.tag_name }}"
fi
echo "tag=${tag}" >> "${GITHUB_OUTPUT}"

- name: Publish GitHub release
uses: softprops/action-gh-release@v3
# Pinned to a commit, not the mutable v3 tag: this is the only step that
# runs with contents: write, so retagging upstream would hand a new
# revision the ability to rewrite this repository's releases.
# softprops/action-gh-release v3.0.2
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Correctness | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact prior contents of the workflow are not available in the review tools, so whether the tag-based release publication was newly added versus retained cannot be established from the head revision alone; the changed workflow nevertheless implements the vulnerable verify-then-publish protocol.
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
A tag can move after `verify` passes but before (or during) `softprops/action-gh-release`, and publication is still performed against the mutable tag name rather than an immutable commit. In that race the release assets can be attached to a release whose tag points at a different commit; `confirm` only makes the workflow red after the bad release already exists and does not delete/repoint the release or assets.

with:
tag_name: ${{ steps.meta.outputs.tag }}
tag_name: ${{ needs.resolve.outputs.tag }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Data Integrity | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact probability of the interleaving depends on repository tag protections and who has permission to move the tag, but those controls do not make the workflow operation atomic.
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
The tag recheck is check-then-use rather than an atomic publish target. A maintainer or attacker who moves the release tag after `Verify the tag still points at the built commit` completes but before `action-gh-release` resolves `tag_name`, can cause the release to be attached to the moved tag while the archives remain from BUILT_SHA; the workflow will not refuse that move.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Correctness | 🟠 High

🧩 Analysis
  • Change relation: introduced
  • Confirmation: independently-verified
  • Reachable: ✅
  • ⚠️ The exact GitHub API behavior under a simultaneous force-move is not executable here, but the workflow clearly performs an unconditional tag-name-based release after a separate point-in-time verification, so the race is inherent in the sequence.
🤖 Prompt for AI agents
In .github/workflows/release.yml, address this finding:
The workflow only detects a tag move after publication; it does not prevent publishing archives to a tag that changed after verification. `verify` reads and compares the tag, then `publish` separately invokes the release action by mutable `tag_name`; a concurrent force-move between those jobs causes the action to attach the already-built archives to the new commit, and `confirm` can only fail after the mismatched release is live.

files: dist/*.tar.gz
generate_release_notes: true


# Its own job, and therefore its own read-only token: the release API attaches
# to a tag name, so a tag moved during publication cannot be refused at that
# instant. Failing afterwards turns what would be a silent mismatch between the
# release and its source into a red run.
confirm:
name: confirm published commit
needs: [resolve, publish]
runs-on: ubuntu-24.04

steps:
- name: Confirm the published tag is still the built commit
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
BUILT_SHA: ${{ needs.resolve.outputs.sha }}
run: |
set -euo pipefail
# Single read of the ref, as in resolve: two requests can pair a SHA
# from one side of a tag move with a type from the other.
ref_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}")"
sha="$(printf '%s' "${ref_json}" | jq -r '.object.sha')"
type="$(printf '%s' "${ref_json}" | jq -r '.object.type')"
# Peeled the same way as resolve, including a tag naming another tag.
for _ in 1 2 3 4 5; do
[[ "${type}" == "tag" ]] || break
tag_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${sha}")"
sha="$(printf '%s' "${tag_json}" | jq -r '.object.sha')"
type="$(printf '%s' "${tag_json}" | jq -r '.object.type')"
done
if [[ "${sha}" != "${BUILT_SHA}" ]]; then
echo "tag ${RELEASE_TAG} moved to ${sha} while publishing; the release does not match ${BUILT_SHA}" >&2
exit 1
fi
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,12 @@ exedev-ctl domain add p1-a-1 app.example.com
exedev-ctl rm p1-a-1
```

Dangerous operations such as `rm`, public share changes, and support-root grants
ask for confirmation by default. Use `--yes` only in reviewed automation.
Dangerous operations ask for confirmation by default: deletions (`rm`,
`pool delete`, `domain rm`), access widening (public shares, share links,
`share add --root`, `share access allow`, `team settings auto-join on`,
support-root grants), and spending (`billing capacity`, `billing credits buy`).
`--yes` is a global flag on both CLIs that skips these prompts; use it only in
reviewed automation. `exedev-k8s destroy` always confirms, even with `--yes`.

Detailed documentation:

Expand Down
8 changes: 6 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,12 @@ exedev-ctl domain add p1-a-1 app.example.com
exedev-ctl rm p1-a-1
```

`rm`、public share 变更、support-root grant 等危险操作默认需要确认。只有在
automation 已经审阅过 action plan 后才使用 `--yes`。
危险操作默认需要确认:删除(`rm`、`pool delete`、`domain rm`)、扩大访问权限
(public share、share link、`share add --root`、`share access allow`、
`team settings auto-join on`、support-root grant)、以及花钱(`billing capacity`、
`billing credits buy`)。`--yes` 是两个 CLI 的 global flag,用于跳过这些确认,
只有在 automation 已经审阅过 action plan 后才使用。`exedev-k8s destroy` 始终需要
确认,即使传入 `--yes`。

详细文档:

Expand Down
31 changes: 27 additions & 4 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,33 @@ These commands require local SSH access to exe.dev.
The CLI covers the top-level commands from the exe.dev CLI Reference:

```text
help doc ls new rm restart rename tag stat cp resize share domain team whoami
ssh-key set-region integrations billing shelley browser ssh grant-support-root
exit exec
help doc ls new rm restart rename tag comment stat cp resize share domain team
pool invite whoami ssh-key set-region integrations billing shelley browser ssh
grant-support-root exit exec
```

`exec` is the fallback command for future exe.dev commands that do not yet have
a typed wrapper.
a typed wrapper. Its arguments are sent as written: no flag is injected into
them, so a command that prompts server-side (`team disable`,
`billing credits buy`) needs its own `--yes` inside the raw command. The global
`--yes` still skips this CLI's own confirmation prompt, and the global `--json`
still applies, because it selects the output format rather than changing what
the command does.

`new --command` was removed: exe.dev no longer lists it among `new`'s options,
so forwarding it only produced a server-side error. If your account still accepts
it, `exedev-ctl exec -- new --command ...` sends it unchanged.

Two documented commands are intentionally left to `exec`. Both are one-time
onboarding steps with no automation value, and both take a token as a positional
argument or flag value, which a typed wrapper would not make any safer:

```sh
exedev-ctl exec -- billing provider link aws --token="$MARKETPLACE_TOKEN"
exedev-ctl exec -- exe0-to-exe1 "$TOKEN"
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expanding a variable keeps the token literal out of your shell history, but the
expanded value still appears in the process arguments of the local `exedev-ctl`
and `ssh` processes, where any local process running as your user can read it.
exe.dev exposes no argument-free input path for these two commands.
28 changes: 24 additions & 4 deletions cli/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,29 @@ ssh exe.dev ...
CLI 覆盖 exe.dev CLI Reference 中的 top-level commands:

```text
help doc ls new rm restart rename tag stat cp resize share domain team whoami
ssh-key set-region integrations billing shelley browser ssh grant-support-root
exit exec
help doc ls new rm restart rename tag comment stat cp resize share domain team
pool invite whoami ssh-key set-region integrations billing shelley browser ssh
grant-support-root exit exec
```

`exec` 是未来 exe.dev commands 尚未提供 typed wrapper 时的 fallback command。
`exec` 是未来 exe.dev commands 尚未提供 typed wrapper 时的 fallback command。它的
arguments 原样发送,不会被注入任何 flag,因此会在服务端要求确认的命令
(`team disable`、`billing credits buy`)需要自己在 raw command 里带 `--yes`。
全局 `--yes` 仍然会跳过本 CLI 自身的确认提示;全局 `--json` 也仍然生效,因为它
选择的是输出格式,不改变命令本身的行为。

`new --command` 已移除:exe.dev 的 `new` 选项列表中已无此项,转发只会得到服务端
错误。如果你的账号仍接受它,可用 `exedev-ctl exec -- new --command ...` 原样发送。

有两个已文档化的 command 不提供 typed wrapper:它们都是一次性接入操作,没有
automation 价值,并且都以 argument 传递 token —— 包装成 typed wrapper 并不会让它
更安全。

```sh
exedev-ctl exec -- billing provider link aws --token="$MARKETPLACE_TOKEN"
exedev-ctl exec -- exe0-to-exe1 "$TOKEN"
```

用变量展开可以避免 token 字面量写进 shell history,但展开后的值仍然出现在本地
`exedev-ctl` 和 `ssh` 进程的 arguments 中,同一用户下的任何本地进程都能读到。
exe.dev 对这两个 command 没有提供不经过 argument 的输入方式。
Loading