From 7858391bd1da30f536ba59656768b09729445687 Mon Sep 17 00:00:00 2001 From: Rick <6553213+veithly@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:30:39 +0800 Subject: [PATCH 1/2] fix: make builds and releases manual and harden Docker inventory --- .../vibeshell/references/docker-containers.md | 116 +++++++++++++++++- .../vibeshell/references/docker-containers.md | 116 +++++++++++++++++- .github/workflows/ci.yml | 18 +-- .github/workflows/release.yml | 62 ++++++---- AGENTS.md | 2 +- CONTRIBUTING.md | 13 +- README.ja.md | 6 +- README.md | 6 +- README.zh-CN.md | 6 +- docs/ISSUE_TRIAGE_2026-09.md | 85 +++++++++++++ docs/RELEASING.md | 41 +++++-- plugins/builtin/docker-containers/plugin.json | 59 ++++++++- plugins/src/lib.rs | 79 ++++++++++++ scripts/check-release-ci.mjs | 57 +++++++++ scripts/tests/workflows.test.mjs | 81 ++++++++++++ .../vibeshell/references/docker-containers.md | 116 +++++++++++++++++- 16 files changed, 802 insertions(+), 61 deletions(-) create mode 100644 docs/ISSUE_TRIAGE_2026-09.md create mode 100644 scripts/check-release-ci.mjs create mode 100644 scripts/tests/workflows.test.mjs diff --git a/.claude/skills/vibeshell/references/docker-containers.md b/.claude/skills/vibeshell/references/docker-containers.md index 2a82d18..035a9bc 100644 --- a/.claude/skills/vibeshell/references/docker-containers.md +++ b/.claude/skills/vibeshell/references/docker-containers.md @@ -1,6 +1,6 @@ # Docker Containers — docker-containers -Plugin `docker-containers` version `1.4.0`. +Plugin `docker-containers` version `1.5.0`. Inspect and manage containers, images, live resource usage and recent container logs through the remote Docker CLI. @@ -18,6 +18,116 @@ Required permissions: `["remote_exec","local_exec"]`. Session types: `["ssh","lo `describe` returns machine-readable action input schemas. `docs` regenerates the current reference, including imported plugins. `run` reuses the selected session. `--confirm` is only for an action the user has explicitly approved; `--sudo` is opt-in and also needs confirmation. No operation bypasses installation, enablement, permission or input checks. Output is bounded and carries timing/truncation metadata. Local targets require a running GUI-owned local session. +## `running-containers` + +List running containers with full IDs; paused and restarting containers are not shell-ready. + +```sh +vibeshell plugins run docker-containers running-containers --session SESSION_ID --inputs '{}' +``` + +Replace SESSION_ID and supply all fields marked required below. Do not execute placeholder values. Append `--confirm` only after consent for this exact action. + +```json +{ + "allowSudo": true, + "description": "List running containers with full IDs; paused and restarting containers are not shell-ready.", + "elevate": false, + "id": "running-containers", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "name": "Running containers", + "output": { + "columns": [ + "ID", + "Name", + "Image", + "State", + "Status", + "Ports" + ], + "delimiter": "\t", + "kind": "table" + }, + "requiresConfirmation": false +} +``` + +## `exited-containers` + +Inspect exited containers separately without starting or restarting them. + +```sh +vibeshell plugins run docker-containers exited-containers --session SESSION_ID --inputs '{}' +``` + +Replace SESSION_ID and supply all fields marked required below. Do not execute placeholder values. Append `--confirm` only after consent for this exact action. + +```json +{ + "allowSudo": true, + "description": "Inspect exited containers separately without starting or restarting them.", + "elevate": false, + "id": "exited-containers", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "name": "Exited containers", + "output": { + "columns": [ + "ID", + "Name", + "Image", + "State", + "Status", + "Ports" + ], + "delimiter": "\t", + "kind": "table" + }, + "requiresConfirmation": false +} +``` + +## `container-inventory` + +Read all container states and full IDs as one JSON object per line, not a JSON array. This does not create a container session. + +```sh +vibeshell plugins run docker-containers container-inventory --session SESSION_ID --inputs '{}' +``` + +Replace SESSION_ID and supply all fields marked required below. Do not execute placeholder values. Append `--confirm` only after consent for this exact action. + +```json +{ + "allowSudo": true, + "description": "Read all container states and full IDs as one JSON object per line, not a JSON array. This does not create a container session.", + "elevate": false, + "id": "container-inventory", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "name": "Container inventory (JSON Lines)", + "output": { + "columns": [], + "delimiter": "\t", + "kind": "text" + }, + "requiresConfirmation": false +} +``` + ## `containers` List running and stopped containers. @@ -215,7 +325,7 @@ Replace SESSION_ID and supply all fields marked required below. Do not execute p ## `exec-command` -Run one non-interactive shell command inside a container. +Run one non-interactive shell command inside a container. This does not create a persistent container session or change the host session. ```sh vibeshell plugins run docker-containers exec-command --session SESSION_ID --inputs '{}' @@ -226,7 +336,7 @@ Replace SESSION_ID and supply all fields marked required below. Do not execute p ```json { "allowSudo": true, - "description": "Run one non-interactive shell command inside a container.", + "description": "Run one non-interactive shell command inside a container. This does not create a persistent container session or change the host session.", "elevate": false, "id": "exec-command", "inputSchema": { diff --git a/.codex/skills/vibeshell/references/docker-containers.md b/.codex/skills/vibeshell/references/docker-containers.md index 2a82d18..035a9bc 100644 --- a/.codex/skills/vibeshell/references/docker-containers.md +++ b/.codex/skills/vibeshell/references/docker-containers.md @@ -1,6 +1,6 @@ # Docker Containers — docker-containers -Plugin `docker-containers` version `1.4.0`. +Plugin `docker-containers` version `1.5.0`. Inspect and manage containers, images, live resource usage and recent container logs through the remote Docker CLI. @@ -18,6 +18,116 @@ Required permissions: `["remote_exec","local_exec"]`. Session types: `["ssh","lo `describe` returns machine-readable action input schemas. `docs` regenerates the current reference, including imported plugins. `run` reuses the selected session. `--confirm` is only for an action the user has explicitly approved; `--sudo` is opt-in and also needs confirmation. No operation bypasses installation, enablement, permission or input checks. Output is bounded and carries timing/truncation metadata. Local targets require a running GUI-owned local session. +## `running-containers` + +List running containers with full IDs; paused and restarting containers are not shell-ready. + +```sh +vibeshell plugins run docker-containers running-containers --session SESSION_ID --inputs '{}' +``` + +Replace SESSION_ID and supply all fields marked required below. Do not execute placeholder values. Append `--confirm` only after consent for this exact action. + +```json +{ + "allowSudo": true, + "description": "List running containers with full IDs; paused and restarting containers are not shell-ready.", + "elevate": false, + "id": "running-containers", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "name": "Running containers", + "output": { + "columns": [ + "ID", + "Name", + "Image", + "State", + "Status", + "Ports" + ], + "delimiter": "\t", + "kind": "table" + }, + "requiresConfirmation": false +} +``` + +## `exited-containers` + +Inspect exited containers separately without starting or restarting them. + +```sh +vibeshell plugins run docker-containers exited-containers --session SESSION_ID --inputs '{}' +``` + +Replace SESSION_ID and supply all fields marked required below. Do not execute placeholder values. Append `--confirm` only after consent for this exact action. + +```json +{ + "allowSudo": true, + "description": "Inspect exited containers separately without starting or restarting them.", + "elevate": false, + "id": "exited-containers", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "name": "Exited containers", + "output": { + "columns": [ + "ID", + "Name", + "Image", + "State", + "Status", + "Ports" + ], + "delimiter": "\t", + "kind": "table" + }, + "requiresConfirmation": false +} +``` + +## `container-inventory` + +Read all container states and full IDs as one JSON object per line, not a JSON array. This does not create a container session. + +```sh +vibeshell plugins run docker-containers container-inventory --session SESSION_ID --inputs '{}' +``` + +Replace SESSION_ID and supply all fields marked required below. Do not execute placeholder values. Append `--confirm` only after consent for this exact action. + +```json +{ + "allowSudo": true, + "description": "Read all container states and full IDs as one JSON object per line, not a JSON array. This does not create a container session.", + "elevate": false, + "id": "container-inventory", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "name": "Container inventory (JSON Lines)", + "output": { + "columns": [], + "delimiter": "\t", + "kind": "text" + }, + "requiresConfirmation": false +} +``` + ## `containers` List running and stopped containers. @@ -215,7 +325,7 @@ Replace SESSION_ID and supply all fields marked required below. Do not execute p ## `exec-command` -Run one non-interactive shell command inside a container. +Run one non-interactive shell command inside a container. This does not create a persistent container session or change the host session. ```sh vibeshell plugins run docker-containers exec-command --session SESSION_ID --inputs '{}' @@ -226,7 +336,7 @@ Replace SESSION_ID and supply all fields marked required below. Do not execute p ```json { "allowSudo": true, - "description": "Run one non-interactive shell command inside a container.", + "description": "Run one non-interactive shell command inside a container. This does not create a persistent container session or change the host session.", "elevate": false, "id": "exec-command", "inputSchema": { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dcdd6c..fb40d80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,10 @@ -name: CI - -on: - pull_request: - branches: [dev, main] - push: - branches: [dev, main] - workflow_dispatch: +name: CI +run-name: Manual CI · ${{ github.ref_name }} + +# Maintainers explicitly run this on the PR head branch or exact release tag. +# PR Target remains automatic; compilation never runs on a push or PR event. +on: + workflow_dispatch: permissions: contents: read @@ -25,7 +24,8 @@ jobs: node-version: '22' cache: npm - run: npm ci - - run: node scripts/check-release.mjs + - run: node scripts/check-release.mjs + - run: node --test scripts/tests/*.test.mjs - run: npm test - run: npm run build - name: Release helper tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5970dc0..1b23ed9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,35 +1,40 @@ -name: Release - -on: - push: - tags: ['v*'] - workflow_dispatch: +name: Release +run-name: Manual release · ${{ inputs.tag }} · publish=${{ inputs.publish }} + +on: + workflow_dispatch: inputs: tag: description: Existing vX.Y.Z tag on main (no automatic version bump) - required: true - type: string + required: true + type: string + publish: + description: Publish after verification (false builds and uploads a draft only) + required: true + type: boolean + default: false permissions: contents: read concurrency: - group: release-${{ inputs.tag || github.ref_name }} + group: release-${{ inputs.tag }} cancel-in-progress: false jobs: prepare: name: Validate release and create draft + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: contents: write - checks: read + actions: read outputs: tag: ${{ steps.metadata.outputs.tag }} version: ${{ steps.metadata.outputs.version }} sha: ${{ steps.metadata.outputs.sha }} env: - TAG: ${{ inputs.tag || github.ref_name }} + TAG: ${{ inputs.tag }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v4 @@ -44,23 +49,19 @@ jobs: set -euo pipefail [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo 'Expected an existing vX.Y.Z tag'; exit 1; } git fetch origin main --tags - SHA=$(git rev-parse --verify "refs/tags/$TAG^{commit}") - git merge-base --is-ancestor "$SHA" origin/main - git checkout --detach "$SHA" + SHA=$(git rev-parse --verify "refs/tags/$TAG^{commit}") + git merge-base --is-ancestor "$SHA" origin/main + # Keep the current gate even when the selected tag predates it. + cp scripts/check-release-ci.mjs "$RUNNER_TEMP/check-release-ci.mjs" + git checkout --detach "$SHA" node scripts/check-release.mjs "$TAG" echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" echo "sha=$SHA" >> "$GITHUB_OUTPUT" - gh api "repos/$GITHUB_REPOSITORY/commits/$SHA/check-runs?per_page=100" > "$RUNNER_TEMP/checks.json" - node - "$RUNNER_TEMP/checks.json" <<'NODE' - const fs = require('node:fs'); - const checks = JSON.parse(fs.readFileSync(process.argv[2])).check_runs; - for (const name of ['Frontend Check', 'Clippy Lint', 'Rust Check (ubuntu-22.04)', 'Rust Check (windows-latest)', 'Rust Check (macos-latest)']) { - if (!checks.some(c => c.name === name && c.app.slug === 'github-actions' && c.conclusion === 'success')) { - throw new Error(`Required CI has not passed for the tagged commit: ${name}`); - } - } - NODE + gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?event=workflow_dispatch&head_sha=$SHA&per_page=100" > "$RUNNER_TEMP/ci-runs.json" + RUN_ID=$(node "$RUNNER_TEMP/check-release-ci.mjs" run "$RUNNER_TEMP/ci-runs.json" "$SHA" "$GITHUB_REPOSITORY") + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/jobs?filter=latest&per_page=100" > "$RUNNER_TEMP/ci-jobs.json" + node "$RUNNER_TEMP/check-release-ci.mjs" jobs "$RUNNER_TEMP/ci-jobs.json" - uses: actions/setup-node@v4 with: node-version: '22' @@ -212,7 +213,7 @@ jobs: retention-days: 7 publish: - name: Verify all assets and publish + name: Verify assets and finalize draft or publication needs: [prepare, build, source] runs-on: ubuntu-latest permissions: @@ -221,6 +222,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.prepare.outputs.tag }} VERSION: ${{ needs.prepare.outputs.version }} + PUBLISH_RELEASE: ${{ inputs.publish }} HAS_APPLE_SIGNING: ${{ secrets.APPLE_CERTIFICATE != '' || secrets.APPLE_SIGNING_IDENTITY != '' }} HAS_APPLE_NOTARIZATION: ${{ secrets.APPLE_ID != '' && secrets.APPLE_PASSWORD != '' && secrets.APPLE_TEAM_ID != '' }} steps: @@ -246,7 +248,7 @@ jobs: cp LICENSE NOTICE assets/ cp licenses/legacy-MIT.txt assets/legacy-MIT.txt (cd assets && sha256sum ./* > SHA256SUMS.txt) - - name: Publish only after all required jobs succeed + - name: Upload verified assets; publish only when explicitly requested run: | set -euo pipefail gh release view "$TAG" --json isDraft > "$RUNNER_TEMP/release.json" @@ -261,4 +263,10 @@ jobs: printf '\nApple signing and notarization were configured; successful bundling completed before publication.\n' >> "$RUNNER_TEMP/notes.md" fi gh release upload "$TAG" assets/* --clobber - gh release edit "$TAG" --notes-file "$RUNNER_TEMP/notes.md" --draft=false --latest + if [[ "$PUBLISH_RELEASE" == 'true' ]]; then + gh release edit "$TAG" --notes-file "$RUNNER_TEMP/notes.md" --draft=false --latest + echo "Published verified release $TAG." >> "$GITHUB_STEP_SUMMARY" + else + gh release edit "$TAG" --notes-file "$RUNNER_TEMP/notes.md" + echo "Built and uploaded $TAG as a draft. Nothing was published or marked latest." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/AGENTS.md b/AGENTS.md index 184a9a3..ccf2461 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ VibeShell is a modern SSH/SFTP desktop terminal built with **Tauri 2** (Rust bac ## Branch and release workflow -All feature, fix and documentation PRs target `dev`, the default integration branch. `main` accepts release promotions from the same repository's `dev`; `master` is historical. See CONTRIBUTING.md. Version tags are explicit; ordinary pushes must not auto-bump or publish releases. VibeShell 1.1.0 is GPL-3.0-only; preserve NOTICE and third-party attribution. +All feature, fix and documentation PRs target `dev`, the default integration branch. `main` accepts release promotions from the same repository's `dev`; `master` is historical. See CONTRIBUTING.md. CI compilation and release packaging/publication are **manual-only** (`workflow_dispatch`); neither pushes, PRs nor tags trigger them. PR Target remains automatic and all existing required CI checks remain enforced: a maintainer runs CI on the PR head before merging. Release runs from `main`, requires successful manual CI on the exact tagged commit, and defaults to an unpublished draft unless `publish=true` is explicitly selected. Do not auto-bump versions or dispatch publication without authorization. VibeShell 1.1.0 is GPL-3.0-only; preserve NOTICE and third-party attribution. ## Architecture diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a0927dc..944a2e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,7 @@ Use Node.js 22.12+ and current stable Rust, with Tauri's platform prerequisites npm ci cargo run --locked -p vibeshell-plugins --example export_references -- --check node scripts/check-release.mjs +node --test scripts/tests/*.test.mjs npm test npm run build cargo fmt --all -- --check @@ -37,6 +38,16 @@ For SSH transport changes, run `bash scripts/test-ssh-compatibility.sh` with Doc A successful build is not proof of UI correctness or universal server compatibility. For UI changes, exercise focus, keyboard navigation, reduced motion, resize and unsaved-edit handling. Include sanitized screenshots when useful. Never publish real server details, tokens, private keys or session recordings in fixtures or test logs. +## Manual GitHub checks + +Pushing code or opening a PR does **not** compile the project. After reviewing the diff, a maintainer explicitly runs **Actions → CI → Run workflow** on the PR's head branch, or: + +```bash +gh workflow run ci.yml --ref fix/short-description +``` + +All existing required checks remain in branch protection; making them manual does not waive them. A new commit needs a new manual run. Do not use `--admin`, fabricate check results, or remove required checks to merge a pending PR. The small, metadata-only **PR Target** check remains automatic and never checks out contributor code. For a fork PR, use a reviewed same-repository branch with the exact head SHA (or a separate integration PR after resolving conflicts); do not run unreviewed fork code with release secrets. + ## Plugin and documentation changes The validated manifests in `plugins/builtin/` define the built-in plugin catalog. Each plugin must expose machine-readable actions and current reference documentation. Details belong in `references/.md`, not in the main Skill. @@ -63,4 +74,4 @@ Report exploitable vulnerabilities through the repository's private security rep ## Releases -Version changes go through `dev`, then a tested promotion to `main`. Do not bump a version automatically on every branch push. Only an explicit matching `vX.Y.Z` tag on `main` starts publication. See [RELEASING](docs/RELEASING.md). +Version changes go through `dev`, then a tested promotion to `main`. Pushing a `vX.Y.Z` tag does **not** build or publish anything. Run CI manually on the exact tagged commit, then run Release from `main` with that existing tag. Release defaults to a draft; only an explicit `publish=true` run publishes after every verification/build succeeds. See [RELEASING](docs/RELEASING.md). diff --git a/README.ja.md b/README.ja.md index a5a60c7..2512c1f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -6,7 +6,7 @@ [English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) - [![CI](https://github.com/veithly/vibeshell/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/veithly/vibeshell/actions/workflows/ci.yml) + [![Manual CI](https://github.com/veithly/vibeshell/actions/workflows/ci.yml/badge.svg?branch=dev&event=workflow_dispatch)](https://github.com/veithly/vibeshell/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/veithly/vibeshell)](https://github.com/veithly/vibeshell/releases) [![GPLv3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) @@ -172,6 +172,10 @@ PR 前に `node scripts/check-release.mjs`、`npm test`、`npm run build`、`car **通常の PR は `dev` 宛てです。** `main` は本リポジトリの `dev` からの検証済みリリース昇格を受け付け、`master` は履歴用です。ビルド操作は使用中アプリの置換や SSH 切断を許可するものではありません。 +CI ビルドとリリースは**手動実行のみ**です。マージ前に PR の最新コミットで CI を実行してください。push やタグ作成ではビルドも公開も始まりません。Release は `main` から実行し、明示的に公開を選択しない限りドラフトのままです。必須チェック、署名、対応ソースの検証は維持されます。 + +Docker の一覧改善と、独立コンテナーセッション・送信プロキシの残作業は [9 月の issue レビュー](docs/ISSUE_TRIAGE_2026-09.md) にまとめています。Docker プラグインのコマンド実行は永続セッションではなく、SSH の SOCKS 転送リスナーは送信プロキシ設定ではありません。 + [貢献ガイド](CONTRIBUTING.md) · [アーキテクチャ](AGENTS.md) · [リリース手順](docs/RELEASING.md) · [共同作業 API](docs/AGENT_COLLABORATION.md) セキュリティ上の問題は [非公開報告](https://github.com/veithly/vibeshell/security/advisories/new) へ。承認はサンドボックスではなく、保護された入力もリモートプログラムのエコーを防げません。応答消失後に変更操作を自動で再実行しないでください。 diff --git a/README.md b/README.md index ab6fcb3..5cc80a6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) - [![CI](https://github.com/veithly/vibeshell/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/veithly/vibeshell/actions/workflows/ci.yml) + [![Manual CI](https://github.com/veithly/vibeshell/actions/workflows/ci.yml/badge.svg?branch=dev&event=workflow_dispatch)](https://github.com/veithly/vibeshell/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/veithly/vibeshell)](https://github.com/veithly/vibeshell/releases) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) @@ -172,6 +172,10 @@ Before a PR: `node scripts/check-release.mjs`, `npm test`, `npm run build`, `car **All normal PRs target `dev`.** `main` receives tested release promotions from this repository's `dev`; `master` is historical. Build commands do not authorize replacing an installed app or ending someone's SSH sessions. +CI builds and releases are **manual-only**. Maintainers run CI on the PR head before merging; pushing code or a version tag does not compile or publish. Release runs from `main` and defaults to a draft unless publication is explicitly selected. Required checks, signatures and complete-source validation remain in place. + +Docker inventory improvements and the remaining container-session/proxy work are tracked in the [September issue review](docs/ISSUE_TRIAGE_2026-09.md). A Docker plugin command is not a persistent container session; an SSH SOCKS forwarding listener is not an outbound proxy setting. + [Contributing](CONTRIBUTING.md) · [Architecture](AGENTS.md) · [Release process](docs/RELEASING.md) · [Collaboration API](docs/AGENT_COLLABORATION.md) Report security concerns through [private security reporting](https://github.com/veithly/vibeshell/security/advisories/new), not with real credentials in an issue. Approval controls are not a sandbox; protected input cannot prevent a remote program from echoing it. Do not automatically replay a mutating command after an ambiguous response loss. diff --git a/README.zh-CN.md b/README.zh-CN.md index 2afb7e2..61988e1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@ [English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) - [![CI](https://github.com/veithly/vibeshell/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/veithly/vibeshell/actions/workflows/ci.yml) + [![手动 CI](https://github.com/veithly/vibeshell/actions/workflows/ci.yml/badge.svg?branch=dev&event=workflow_dispatch)](https://github.com/veithly/vibeshell/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/veithly/vibeshell)](https://github.com/veithly/vibeshell/releases) [![GPLv3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) @@ -172,6 +172,10 @@ npm run tauri -- dev **普通 PR 一律先到 `dev`。** `main` 只接受本仓库 `dev` 的发布晋级,`master` 保留历史。构建代码不等于允许覆盖正在使用的应用或结束别人的 SSH 会话。 +CI 构建和发版都改为**手动触发**:维护者在合并前对 PR 的最新提交手动运行 CI,推送代码或版本标签不会编译或发布。Release 必须从 `main` 运行,默认只生成草稿,明确勾选发布才会公开。必需检查、签名和完整源码校验仍然保留。 + +Docker 清单改进以及容器独立会话、出站代理的剩余工作见[九月 issue 审查](docs/ISSUE_TRIAGE_2026-09.md)。Docker 插件执行命令不等于持久容器会话,SSH 的 SOCKS 转发监听也不等于出站代理配置。 + [贡献指南](CONTRIBUTING.md) · [架构](AGENTS.md) · [发布流程](docs/RELEASING.md) · [协作接口](docs/AGENT_COLLABORATION.md) 安全问题请走 [私密报告](https://github.com/veithly/vibeshell/security/advisories/new),不要在 Issue 里贴真实凭据。审批不是沙箱,敏感输入保护也不能阻止远端程序回显;响应丢失后,不应自动重放可能已执行的修改命令。 diff --git a/docs/ISSUE_TRIAGE_2026-09.md b/docs/ISSUE_TRIAGE_2026-09.md new file mode 100644 index 0000000..1b07532 --- /dev/null +++ b/docs/ISSUE_TRIAGE_2026-09.md @@ -0,0 +1,85 @@ +# September 2026 PR and issue review + +Reviewed on **2026-09-29**. This document distinguishes implemented changes from proposed work. The application version remains 1.1.0; changing source does not update an installed application or publish a release. + +## Pull requests + +| PR | Decision | Evidence and remaining work | +| --- | --- | --- | +| [#15](https://github.com/veithly/vibeshell/pull/15) | Merged into `main` | Workflow-only annotated-tag checkout and release metadata fixes; all required checks passed. | +| [#16](https://github.com/veithly/vibeshell/pull/16) | Merged into `dev` | Three-language product tour and five screenshots of real components using synthetic data. The fixture is a separate development-only loopback entry; all required checks passed. | +| [#10](https://github.com/veithly/vibeshell/pull/10) | Keep open; changes required | Head `e8fd8d0` has not changed since the September 18 review and conflicts with `dev`. Do not merge by merely resolving textual conflicts. | +| [#11](https://github.com/veithly/vibeshell/pull/11) | Keep open; changes required | Head `b81e609` is unchanged since review, conflicts with `dev`, and includes #10. Session and file-operation contracts still need implementation and regression fixtures. | + +### #10: preserve CLI usability without credential regressions + +The headless create/delete workflow is useful. The blockers are in the implementation, not in the feature: + +- `cli/src/commands/server.rs::env_nonempty` and `commands/server.rs::add_server_spec` trim passwords/passphrases. Presence checks must not modify secret bytes, including whitespace-only secrets. +- Creating a group, server and encrypted credentials must be one database transaction, including associations and sync outbox changes. Inject a failure at each mutation and assert rollback. Deleting credentials before a failing metadata delete must not leave a broken saved server. +- `AddServerSpec` derives `Debug` and carries credentials; the new IPC variant falls through the existing log-redaction match. Add sentinel tests for password, key material and passphrase, including activity/error paths. +- The proposed GUI adapter drops `ServerInput.credential_id` when constructing `AddServerSpec`. Preserve existing credential associations and add a GUI-backend regression test. +- The new delete CLI currently has no confirmation flag or prompt. Require explicit confirmation, with a deliberate noninteractive option; refuse ambiguous names. Invalid `host:port` inputs must fail rather than becoming literal hostnames. + +Rebase on `dev`, reuse the current transactional storage/credential paths, then run parser, rollback, redaction and full workspace checks. No unsafe contributor code was executed as part of this review. + +### #11: split transport support from unimplemented file semantics + +First resolve #10. A smaller initial Teleport PR should establish a reliable session lifecycle and capability reporting. File features may explicitly report unsupported until their normal contracts are met. + +Use asynchronously drained, bounded stdout/stderr, concurrent stdin, timeouts and cancellation for `tsh`. The current synchronous `Command.output()` and write-before-drain paths can hang or exhaust memory. Authentication failures and EOF must drive real state transitions; process spawn or a fixed sleep is not connection success. Preserve GUI/daemon ownership, quick commands, plugin execution and teardown. + +Do not replace exclusive file creation with `cat >`, binary reads with lossy text, or structured directories with trimmed `ls` lines. Recursive `scp` is not directory synchronization: exclusions, nested ignore rules, deletion policy and actual transfer counts must be honored. Fixtures should cover binary bytes, whitespace/Unicode names, overwrite refusal, ignored-file retention, failed authentication, cancellation and bounded process I/O. + +## #9: Docker containers as independent sessions + +Issue: [#9](https://github.com/veithly/vibeshell/issues/9). + +### Implemented in this change + +The Docker Containers plugin now starts with a **Running containers** action and has a separate **Exited containers** action. Both use full container IDs and expose machine-readable state alongside status. A **Container inventory (JSON Lines)** action returns one Docker-formatted JSON object per line for agent consumers. The existing all-container action remains available; these queries do not start containers or elevate automatically. + +Container operands are separated from Docker options with `--`; inspect is restricted to container objects. Existing mutation confirmations and optional sudo controls remain. Plugin tests cover inventory filters, full IDs, read-only defaults and option-boundary protection. Generated references are shared by all three Skill distributions. + +This is inventory groundwork, **not** one-click container sessions. There is no new collapsible sidebar or container PTY in this change. `exec-command` remains a single noninteractive command. + +### Required next implementation + +Add an explicit container context to a child session: owning process, parent SSH server/session identity, immutable full container ID, selected user and working directory. Do not identify a live session only by a reusable container name. + +The interactive PTY and every independent command path must use that context. Today `Session::exec_command_with_stdin`, quick commands and plugin operations open SSH exec channels on the host. Merely sending `docker exec -it ...` into a terminal would leave agents executing on the host. Route GUI, CLI, MCP, quick commands and plugin execution consistently, and show the container context in tabs and activity records. + +Closing the container session must close its exec process, not stop the container or kill the parent session. Container termination must disconnect the child explicitly, never silently leave a host shell under a container label. Check actual exec success; preserve resize, input, cancellation and output replay. A stopped container stays stopped unless the user explicitly authorizes starting it. Shell selection must handle images without Bash or any shell with a clear error. + +Initially reject container-file operations that are not implemented. Host SFTP must never be presented as the container filesystem. A later file transport needs binary-safe reads, exclusive writes, path validation and real sync semantics. + +The UI should fetch inventory when the container panel opens, with refresh and visible permission/connection errors. Show running entries first and collapsed exited entries; use the full ID when opening a new tab. Avoid automatic polling or sudo on every SSH connection. Test two containers concurrently, agent-created tab synchronization, host/container execution identity, container stop/restart, parent disconnect and independent child cleanup using isolated fixtures. + +## #12: outbound proxy support + +Issue: [#12](https://github.com/veithly/vibeshell/issues/12). **Design only; proxy protocols are not implemented by this change.** Existing SSH SOCKS forwarding is a listener for traffic through an established SSH connection, not an outbound proxy setting. + +### Scope and existing network paths + +| Path | Current implementation | Required integration | +| --- | --- | --- | +| SSH host-key probe and authentication | Shared `SshClient::establish_connection` in `src-tauri/src/ssh/client.rs` | Establish the selected proxy tunnel before SSH; use the same route for probe and authentication. Preserve TOFU and hostname/port identity. | +| SSH exec, SFTP and forwarding | Channels on established SSH connections | Reuse the proxied transport rather than opening accidental direct connections. Include jump-host entry connections. | +| Cloud sync | `reqwest::Client` in `src-tauri/src/cloud_sync/providers.rs` | Apply the same policy, credential handling and bypass rules. | +| Model prediction and update metadata | Frontend `fetch` in `src/lib/aiCommandPrediction.ts` and `src/stores/updateStore.ts` | Route through a controlled native HTTP path or explicitly configured WebView networking; a Rust environment variable alone does not configure browser fetch. | +| Native updater, external agents and remote commands | Separate networking owners | Audit updater downloads separately. Locally launched tools and commands running on a remote host must have explicit scope; do not claim they inherit a global proxy automatically. | + +### Proposed delivery order and safety contract + +1. Define a shared proxy configuration with explicit direct/system/custom modes and per-server overrides. Store proxy credentials with the existing encrypted credential mechanism, not in URLs, command arguments or activity logs. Preserve exact password bytes; provide environment/stdin or a credential reference rather than `--proxy-auth user:pass` in argv. +2. Implement a timeout/cancellation-aware tunnel dialer for HTTP CONNECT and SOCKS5. Reuse the SSH stream connection entry point so host-key checks precede authentication. Distinguish local and proxy-side DNS and document supported authentication. No automatic direct fallback when a proxy is configured. +3. Add HTTPS-proxy TLS verification and SOCKS4/4a with an explicit DNS/IPv6 compatibility matrix. Do not disable certificate verification to support a corporate proxy; expose a deliberate trust configuration. Unsupported destination/protocol combinations must fail clearly. +4. Apply the policy to the HTTP and updater paths above before advertising application-wide coverage. Expose unsupported/external traffic honestly. Redact proxy credentials in connection errors and give actionable authentication, certificate, DNS and timeout diagnostics. + +Use loopback proxy fixtures with generated credentials. Cover CONNECT refusal, SOCKS authentication, malformed replies, timeout, cancellation, IPv4/IPv6, local versus proxy DNS, TLS rejection, exact credential bytes and GUI/CLI parity. Assert that an unavailable proxy never causes a direct connection. No real saved servers or live proxy credentials belong in these tests. + +## Integration and release boundaries + +Prioritize #10's credential-safe foundation and #9's shared execution-context design before adding another independent transport. Proxy support can then use the shared connection boundary; Teleport needs its own bounded process transport rather than pretending to be raw SSH/SFTP. + +CI compilation and releases are now explicitly manual; all required merge checks remain enforced. See [contribution checks](../CONTRIBUTING.md#manual-github-checks) and [release procedure](RELEASING.md). This review does not close #9 or #12, publish a new version, install a new binary, or terminate user sessions. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 3f15732..b3cf221 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -2,31 +2,54 @@ ## Branch contract -`dev` is the default branch for all normal PRs. `main` receives only release promotions from this repository's `dev`. `master` is historical. Branch protection requires the PR-target check and CI; force pushes and branch deletion are disallowed on active branches. +`dev` is the default branch for all normal PRs. `main` receives only release promotions from this repository's `dev`. `master` is historical. Branch protection still requires PR Target, Frontend Check, Clippy Lint and all three Rust Check jobs; force pushes and branch deletion are disallowed on active branches. -A release is an explicit maintainer action, not an automatic side effect of a documentation or source push. There is no auto-increment bot commit. +**Both CI compilation and release packaging/publication are manual-only.** A push, PR or version tag starts neither workflow. The metadata-only PR Target workflow remains automatic. There is no auto-increment bot commit, and no workflow automatically dispatches another workflow. ## Prepare and promote Update the workspace version, npm manifest and lockfile, the three local Cargo.lock packages, Tauri version, Codex plugin version and Claude marketplace versions in one PR to `dev`. Independent built-in plugin versions are not the application version and should change only when that plugin changes. -Run `node scripts/check-release.mjs`, regenerate/check plugin references, run frontend build/tests, strict Clippy and all workspace tests. Use the loopback SSH fixture for transport changes. Review dependency advisories, licensing and compatibility; do not hide failed scans in release notes. +Run `node scripts/check-release.mjs`, `node --test scripts/tests/*.test.mjs`, regenerate/check plugin references, run frontend build/tests, strict Clippy and all workspace tests. Use the loopback SSH fixture for transport changes. Review dependency advisories, licensing and compatibility; do not hide failed scans in release notes. -Open the promotion PR with `gh pr create --base main --head dev`. Wait for required checks. Merge using a merge commit so the tested integration ancestry is preserved. No feature PR goes directly to `main`. +After reviewing a normal PR, run **Actions → CI → Run workflow**, selecting its head branch, or `gh workflow run ci.yml --ref `. New commits require a fresh manual run. Required checks are not waived just because there is no automatic trigger. See [CONTRIBUTING](../CONTRIBUTING.md#manual-github-checks) for fork PRs. -## Tag and publish +Open the promotion PR with `gh pr create --base main --head dev`, then explicitly run `gh workflow run ci.yml --ref dev`. Wait for all required checks and merge using a merge commit so integration ancestry is preserved. No feature PR goes directly to `main`. Because that merge creates a new commit, its release validation must run on the resulting `main` commit, not merely on the earlier PR head. + +## Tag, verify and build a draft ```bash git fetch origin git switch main git merge --ff-only origin/main -git tag -a v1.1.0 -m 'VibeShell 1.1.0' -git push origin v1.1.0 +# The version must already have been prepared and promoted above. +TAG="v$(node -p 'require("./package.json").version')" +git tag -a "$TAG" -m "VibeShell ${TAG#v}" +git push origin "$TAG" + +# Pushing the tag does nothing else. Explicitly test that exact commit: +gh workflow run ci.yml --ref "$TAG" +``` + +Never move or recreate an existing tag. After the tag's manual CI run has succeeded, run **Actions → Release → Run workflow**, select branch **main**, enter the existing tag and leave **publish** unchecked. The CLI equivalent is: + +```bash +gh workflow run release.yml --ref main -f tag="$TAG" -f publish=false ``` -Use the actual prepared version instead of copying this example for a different release. Tags are immutable: never move a published tag to a different commit. A failed workflow can be rerun for the same tag through **Release → Run workflow**, specifying that existing tag and running the workflow from `main`. +The workflow only accepts dispatches from `main`. It validates that the tag resolves to a commit on `main` and that its version matches every application manifest. The latest manual CI run for that exact commit must have succeeded in this repository's CI workflow, including frontend, Clippy and Linux/Windows/macOS Rust jobs. An old successful check, an automatic run, a skipped platform or a newer failed attempt is insufficient. CI run/job validation uses the current gate from `main`, even when a selected tag predates that helper. + +Release validates updater signing, creates/retains a draft, builds desktop and native CLI packages for Windows x64, macOS arm64/x64 and Linux x64, and assembles matching source materials. Only after every platform, source bundle, signature and checksum check succeeds are the complete assets uploaded. With the default `publish=false`, the release stays a draft and is not marked latest. + +## Publish explicitly + +To authorize publication, explicitly select **publish** in a manual Release run from `main`, or: + +```bash +gh workflow run release.yml --ref main -f tag="$TAG" -f publish=true +``` -The release workflow validates that the tag resolves to a commit on `main` and its version matches every application manifest. It validates updater signing, creates/retains a draft, builds desktop and native CLI packages for Windows x64, macOS arm64/x64 and Linux x64, and assembles matching source materials. Only after all platforms and source packaging succeed does it publish `latest.json`, checksums and the release. +This is a full verified build-and-publish run, not a shortcut that publishes unchecked files from an earlier draft. A separate draft build is optional; a maintainer may choose `publish=true` on the first authorized run. Pushing a tag, running CI, or completing a draft build never publishes on its own. A failed or partial run must remain a draft. Do not mark it latest or overwrite a previously published release to work around failures. Publishing has no automatic rollback: a regression requires a new version with a tested fix. diff --git a/plugins/builtin/docker-containers/plugin.json b/plugins/builtin/docker-containers/plugin.json index abe2709..1d90136 100644 --- a/plugins/builtin/docker-containers/plugin.json +++ b/plugins/builtin/docker-containers/plugin.json @@ -3,7 +3,7 @@ "id": "docker-containers", "name": "Docker Containers", "description": "Inspect and manage containers, images, live resource usage and recent container logs through the remote Docker CLI.", - "version": "1.4.0", + "version": "1.5.0", "author": "VibeShell", "category": "containers", "icon": "box", @@ -18,6 +18,53 @@ "entry": { "type": "commands", "actions": [ + { + "id": "running-containers", + "name": "Running containers", + "description": "List running containers with full IDs; paused and restarting containers are not shell-ready.", + "program": "docker", + "args": [ + "ps", + "--filter", + "status=running", + "--no-trunc", + "--format", + "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Ports}}" + ], + "allowSudo": true, + "output": { + "kind": "table", + "columns": ["ID", "Name", "Image", "State", "Status", "Ports"] + } + }, + { + "id": "exited-containers", + "name": "Exited containers", + "description": "Inspect exited containers separately without starting or restarting them.", + "program": "docker", + "args": [ + "ps", + "--all", + "--filter", + "status=exited", + "--no-trunc", + "--format", + "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Ports}}" + ], + "allowSudo": true, + "output": { + "kind": "table", + "columns": ["ID", "Name", "Image", "State", "Status", "Ports"] + } + }, + { + "id": "container-inventory", + "name": "Container inventory (JSON Lines)", + "description": "Read all container states and full IDs as one JSON object per line, not a JSON array. This does not create a container session.", + "program": "docker", + "args": ["ps", "--all", "--no-trunc", "--format", "{{json .}}"], + "allowSudo": true + }, { "id": "containers", "name": "Containers", @@ -94,6 +141,7 @@ "logs", "--tail", "200", + "--", "{{input.container}}" ], "inputs": [ @@ -113,6 +161,9 @@ "program": "docker", "args": [ "inspect", + "--type", + "container", + "--", "{{input.container}}" ], "inputs": [ @@ -128,10 +179,11 @@ { "id": "exec-command", "name": "Run in container", - "description": "Run one non-interactive shell command inside a container.", + "description": "Run one non-interactive shell command inside a container. This does not create a persistent container session or change the host session.", "program": "docker", "args": [ "exec", + "--", "{{input.container}}", "sh", "-lc", @@ -161,6 +213,7 @@ "program": "docker", "args": [ "start", + "--", "{{input.container}}" ], "inputs": [ @@ -181,6 +234,7 @@ "program": "docker", "args": [ "stop", + "--", "{{input.container}}" ], "inputs": [ @@ -201,6 +255,7 @@ "program": "docker", "args": [ "restart", + "--", "{{input.container}}" ], "inputs": [ diff --git a/plugins/src/lib.rs b/plugins/src/lib.rs index e1334b1..752bbde 100644 --- a/plugins/src/lib.rs +++ b/plugins/src/lib.rs @@ -848,6 +848,9 @@ mod tests { assert_eq!( action_ids, HashSet::from([ + "running-containers", + "exited-containers", + "container-inventory", "containers", "stats", "images", @@ -892,6 +895,82 @@ mod tests { .starts_with("sudo -n docker ps")); } + #[test] + fn docker_inventory_is_read_only_and_keeps_full_container_identity() { + let catalog = builtin_catalog().unwrap(); + let docker = catalog + .iter() + .find(|p| p.id == "docker-containers") + .unwrap(); + let PluginEntry::Commands { actions } = &docker.entry else { + panic!("expected Docker command actions"); + }; + assert_eq!(actions[0].id, "running-containers"); + for (id, state) in [ + ("running-containers", Some("status=running")), + ("exited-containers", Some("status=exited")), + ("container-inventory", None), + ] { + let action = actions.iter().find(|a| a.id == id).unwrap(); + assert_eq!(action.program, "docker"); + assert_eq!(action.args[0], "ps"); + assert!(action.args.iter().any(|arg| arg == "--no-trunc")); + assert!(!action.requires_confirmation); + assert!(!action.elevate); + if let Some(state) = state { + assert!(action + .args + .windows(2) + .any(|pair| pair == ["--filter", state])); + assert_eq!(action.output.kind, PluginOutputKind::Table); + assert_eq!(action.output.columns.len(), 6); + } else { + assert!(action.args.iter().any(|arg| arg == "{{json .}}")); + } + assert_eq!( + action.args.iter().any(|arg| arg == "--all"), + id != "running-containers" + ); + assert!(render_command(action, &BTreeMap::new(), false, false).is_ok()); + } + } + + #[test] + fn docker_container_operands_cannot_be_interpreted_as_options() { + let catalog = builtin_catalog().unwrap(); + let docker = catalog + .iter() + .find(|p| p.id == "docker-containers") + .unwrap(); + let PluginEntry::Commands { actions } = &docker.entry else { + panic!("expected Docker command actions"); + }; + for id in [ + "logs", + "inspect", + "exec-command", + "start-container", + "stop-container", + "restart-container", + ] { + let action = actions.iter().find(|a| a.id == id).unwrap(); + assert!(action + .args + .windows(2) + .any(|pair| pair == ["--", "{{input.container}}"])); + let mut inputs = + BTreeMap::from([("container".to_string(), serde_json::json!("--privileged"))]); + if id == "exec-command" { + inputs.insert("command".to_string(), serde_json::json!("id")); + } + let rendered = render_command(action, &inputs, false, false).unwrap(); + assert!(rendered.contains(" -- --privileged")); + if id == "exec-command" || id.ends_with("-container") { + assert!(action.requires_confirmation); + } + } + } + #[test] fn redis_plugin_falls_back_to_docker_exec() { let catalog = builtin_catalog().expect("built-in catalog should be valid"); diff --git a/scripts/check-release-ci.mjs b/scripts/check-release-ci.mjs new file mode 100644 index 0000000..5d9cd85 --- /dev/null +++ b/scripts/check-release-ci.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// Validate evidence from the CI workflow, not unrelated checks with similar names. +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +export const REQUIRED_JOBS = [ + 'Frontend Check', + 'Clippy Lint', + 'Rust Check (ubuntu-22.04)', + 'Rust Check (windows-latest)', + 'Rust Check (macos-latest)', +]; + +export function selectManualCiRun(payload, sha, repository) { + if (!Array.isArray(payload.workflow_runs)) throw new Error('Invalid CI workflow response'); + const runs = payload.workflow_runs.filter(run => + run.head_sha === sha && run.event === 'workflow_dispatch' + && run.path === '.github/workflows/ci.yml' + && run.head_repository?.full_name === repository + && run.repository?.full_name === repository); + const latest = runs.sort((a, b) => b.id - a.id)[0]; + if (!latest) { + throw new Error('Run CI manually on the exact release tag/commit before releasing'); + } + if (!Number.isSafeInteger(latest.id) || latest.id <= 0 + || latest.status !== 'completed' || latest.conclusion !== 'success') { + throw new Error('The latest manual CI run for this commit has not completed successfully'); + } + return latest.id; +} + +export function validateCiJobs(payload) { + if (!Array.isArray(payload.jobs) || payload.total_count !== payload.jobs.length) { + throw new Error('Incomplete CI jobs response'); + } + for (const name of REQUIRED_JOBS) { + const job = payload.jobs.find(candidate => candidate.name === name); + if (job?.status !== 'completed' || job.conclusion !== 'success') { + throw new Error(`Required manual CI job has not passed: ${name}`); + } + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const [mode, file, sha, repository] = process.argv.slice(2); + if (!file || !['run', 'jobs'].includes(mode) || (mode === 'run' && (!sha || !repository))) { + throw new Error('Usage: check-release-ci.mjs run | jobs '); + } + const payload = JSON.parse(readFileSync(file, 'utf8')); + if (mode === 'run') console.log(selectManualCiRun(payload, sha, repository)); + else validateCiJobs(payload); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/tests/workflows.test.mjs b/scripts/tests/workflows.test.mjs new file mode 100644 index 0000000..f466923 --- /dev/null +++ b/scripts/tests/workflows.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { REQUIRED_JOBS, selectManualCiRun, validateCiJobs } from '../check-release-ci.mjs'; + +const sha = 'a'.repeat(40); +const repository = 'owner/repo'; +const run = { + id: 42, head_sha: sha, event: 'workflow_dispatch', path: '.github/workflows/ci.yml', + head_repository: { full_name: repository }, repository: { full_name: repository }, + status: 'completed', conclusion: 'success', +}; +const select = runs => selectManualCiRun({ workflow_runs: runs }, sha, repository); +const jobs = () => ({ + total_count: REQUIRED_JOBS.length, + jobs: REQUIRED_JOBS.map(name => ({ name, status: 'completed', conclusion: 'success' })), +}); + +test('selects a successful manual CI run for the exact repository and commit', () => { + assert.equal(select([run]), 42); +}); + +test('does not accept automatic, foreign, wrong-workflow or wrong-commit evidence', () => { + for (const override of [ + { event: 'push' }, { head_sha: 'b'.repeat(40) }, { path: '.github/workflows/other.yml' }, + { head_repository: { full_name: 'fork/repo' } }, { repository: { full_name: 'fork/repo' } }, + ]) assert.throws(() => select([{ ...run, ...override }]), /Run CI manually/); + assert.throws(() => select([]), /Run CI manually/); +}); + +test('an older success cannot hide a newer failed, cancelled or running attempt', () => { + for (const override of [ + { conclusion: 'failure' }, { conclusion: 'cancelled' }, { conclusion: 'skipped' }, + { status: 'in_progress', conclusion: null }, + ]) assert.throws(() => select([run, { ...run, id: 43, ...override }]), /latest manual CI/); +}); + +test('selects the newest successful run independent of response order', () => { + assert.equal(select([{ ...run, id: 43 }, run]), 43); +}); + +test('requires every platform, frontend and lint job to have really succeeded', () => { + assert.doesNotThrow(() => validateCiJobs(jobs())); + for (const conclusion of ['failure', 'cancelled', 'skipped', null]) { + const payload = jobs(); + payload.jobs[0].conclusion = conclusion; + assert.throws(() => validateCiJobs(payload), /Required manual CI job/); + } + const payload = jobs(); + payload.jobs.pop(); + payload.total_count--; + assert.throws(() => validateCiJobs(payload), /Rust Check/); +}); + +test('rejects malformed or incomplete evidence', () => { + assert.throws(() => selectManualCiRun({}, sha, repository), /Invalid CI/); + assert.throws(() => select([{ ...run, id: -1 }]), /latest manual CI/); + const payload = jobs(); + payload.total_count++; + assert.throws(() => validateCiJobs(payload), /Incomplete CI/); +}); + +// These files deliberately use a block-form, top-level `on:` section. Reject +// shape changes too, so adding an automatic trigger cannot evade this guard. +test('CI and release have only the manual trigger', () => { + for (const file of ['ci.yml', 'release.yml']) { + const text = readFileSync(new URL(`../../.github/workflows/${file}`, import.meta.url), 'utf8').replaceAll('\r\n', '\n'); + const section = text.match(/^on:\n((?:[ \t].*\n|\n)+)/m); + assert.ok(section, `${file}: expected block-form on section`); + const events = [...section[1].matchAll(/^ ([a-z_]+):/gm)].map(match => match[1]); + assert.deepEqual(events, ['workflow_dispatch'], file); + } +}); + +test('release is restricted to main and defaults to an unpublished draft', () => { + const text = readFileSync(new URL('../../.github/workflows/release.yml', import.meta.url), 'utf8'); + assert.match(text, /if: github.event_name == 'workflow_dispatch' && github.ref == 'refs\/heads\/main'/); + assert.match(text, /publish:[\s\S]*?type: boolean\s+default: false/); + assert.match(text, /if \[\[ "\$PUBLISH_RELEASE" == 'true' \]\]; then\s+gh release edit[^\n]*--draft=false --latest/); + assert.doesNotMatch(text, /inputs\.tag \|\| github\.ref_name/); +}); diff --git a/skills/vibeshell/references/docker-containers.md b/skills/vibeshell/references/docker-containers.md index 2a82d18..035a9bc 100644 --- a/skills/vibeshell/references/docker-containers.md +++ b/skills/vibeshell/references/docker-containers.md @@ -1,6 +1,6 @@ # Docker Containers — docker-containers -Plugin `docker-containers` version `1.4.0`. +Plugin `docker-containers` version `1.5.0`. Inspect and manage containers, images, live resource usage and recent container logs through the remote Docker CLI. @@ -18,6 +18,116 @@ Required permissions: `["remote_exec","local_exec"]`. Session types: `["ssh","lo `describe` returns machine-readable action input schemas. `docs` regenerates the current reference, including imported plugins. `run` reuses the selected session. `--confirm` is only for an action the user has explicitly approved; `--sudo` is opt-in and also needs confirmation. No operation bypasses installation, enablement, permission or input checks. Output is bounded and carries timing/truncation metadata. Local targets require a running GUI-owned local session. +## `running-containers` + +List running containers with full IDs; paused and restarting containers are not shell-ready. + +```sh +vibeshell plugins run docker-containers running-containers --session SESSION_ID --inputs '{}' +``` + +Replace SESSION_ID and supply all fields marked required below. Do not execute placeholder values. Append `--confirm` only after consent for this exact action. + +```json +{ + "allowSudo": true, + "description": "List running containers with full IDs; paused and restarting containers are not shell-ready.", + "elevate": false, + "id": "running-containers", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "name": "Running containers", + "output": { + "columns": [ + "ID", + "Name", + "Image", + "State", + "Status", + "Ports" + ], + "delimiter": "\t", + "kind": "table" + }, + "requiresConfirmation": false +} +``` + +## `exited-containers` + +Inspect exited containers separately without starting or restarting them. + +```sh +vibeshell plugins run docker-containers exited-containers --session SESSION_ID --inputs '{}' +``` + +Replace SESSION_ID and supply all fields marked required below. Do not execute placeholder values. Append `--confirm` only after consent for this exact action. + +```json +{ + "allowSudo": true, + "description": "Inspect exited containers separately without starting or restarting them.", + "elevate": false, + "id": "exited-containers", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "name": "Exited containers", + "output": { + "columns": [ + "ID", + "Name", + "Image", + "State", + "Status", + "Ports" + ], + "delimiter": "\t", + "kind": "table" + }, + "requiresConfirmation": false +} +``` + +## `container-inventory` + +Read all container states and full IDs as one JSON object per line, not a JSON array. This does not create a container session. + +```sh +vibeshell plugins run docker-containers container-inventory --session SESSION_ID --inputs '{}' +``` + +Replace SESSION_ID and supply all fields marked required below. Do not execute placeholder values. Append `--confirm` only after consent for this exact action. + +```json +{ + "allowSudo": true, + "description": "Read all container states and full IDs as one JSON object per line, not a JSON array. This does not create a container session.", + "elevate": false, + "id": "container-inventory", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object" + }, + "name": "Container inventory (JSON Lines)", + "output": { + "columns": [], + "delimiter": "\t", + "kind": "text" + }, + "requiresConfirmation": false +} +``` + ## `containers` List running and stopped containers. @@ -215,7 +325,7 @@ Replace SESSION_ID and supply all fields marked required below. Do not execute p ## `exec-command` -Run one non-interactive shell command inside a container. +Run one non-interactive shell command inside a container. This does not create a persistent container session or change the host session. ```sh vibeshell plugins run docker-containers exec-command --session SESSION_ID --inputs '{}' @@ -226,7 +336,7 @@ Replace SESSION_ID and supply all fields marked required below. Do not execute p ```json { "allowSudo": true, - "description": "Run one non-interactive shell command inside a container.", + "description": "Run one non-interactive shell command inside a container. This does not create a persistent container session or change the host session.", "elevate": false, "id": "exec-command", "inputSchema": { From 0af85c2014e7ba41f88d32ad08bfda2d628c3721 Mon Sep 17 00:00:00 2001 From: Rick <6553213+veithly@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:49:09 +0800 Subject: [PATCH 2/2] fix: report verified manual CI results to required PR statuses --- .github/workflows/ci.yml | 61 +++++++++++++--- CONTRIBUTING.md | 2 + docs/RELEASING.md | 2 + scripts/check-release-ci.mjs | 13 +++- scripts/report-ci-status.mjs | 89 +++++++++++++++++++++++ scripts/tests/ci-status.test.mjs | 120 +++++++++++++++++++++++++++++++ scripts/tests/workflows.test.mjs | 13 ++++ 7 files changed, 288 insertions(+), 12 deletions(-) create mode 100644 scripts/report-ci-status.mjs create mode 100644 scripts/tests/ci-status.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb40d80..dcd44f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,29 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -jobs: - check-frontend: - name: Frontend Check +jobs: + status-start: + name: Initialize required CI statuses + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + statuses: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: node scripts/report-ci-status.mjs start + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + check-frontend: + name: Frontend Check + needs: status-start runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -31,8 +51,9 @@ jobs: - name: Release helper tests run: python3 -m unittest discover -s scripts/tests -p 'test_*.py' - check-backend: - name: Rust Check (${{ matrix.platform }}) + check-backend: + name: Rust Check (${{ matrix.platform }}) + needs: status-start strategy: fail-fast: false matrix: @@ -66,8 +87,9 @@ jobs: rustup target add aarch64-apple-ios-sim cargo check --locked --manifest-path src-tauri/Cargo.toml --target aarch64-apple-ios-sim - check-clippy: - name: Clippy Lint + check-clippy: + name: Clippy Lint + needs: status-start runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -86,5 +108,26 @@ jobs: rustup toolchain install stable --profile minimal --component clippy rustup default stable - run: cargo clippy --workspace --all-targets --locked -- -D warnings - - name: Isolated OpenSSH compatibility - run: bash scripts/test-ssh-compatibility.sh + - name: Isolated OpenSSH compatibility + run: bash scripts/test-ssh-compatibility.sh + + status-finish: + name: Report verified CI statuses + needs: [status-start, check-frontend, check-backend, check-clippy] + if: always() + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + statuses: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: node scripts/report-ci-status.mjs finish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 944a2e3..660c6a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,8 @@ gh workflow run ci.yml --ref fix/short-description All existing required checks remain in branch protection; making them manual does not waive them. A new commit needs a new manual run. Do not use `--admin`, fabricate check results, or remove required checks to merge a pending PR. The small, metadata-only **PR Target** check remains automatic and never checks out contributor code. For a fork PR, use a reviewed same-repository branch with the exact head SHA (or a separate integration PR after resolving conflicts); do not run unreviewed fork code with release secrets. +GitHub does not count `workflow_dispatch` job checks directly toward PR requirements. The manual workflow first marks the five required commit statuses pending, then reports each actual job's result using the GitHub Actions token. Missing, failed, cancelled, skipped or incomplete evidence never produces success; a superseded run cannot intentionally replace a newer run's results. Only the two status-reporting jobs have `statuses: write`; build/test jobs remain read-only. Each status links to its real run. This reporting is not an override and does not start CI automatically. + ## Plugin and documentation changes The validated manifests in `plugins/builtin/` define the built-in plugin catalog. Each plugin must expose machine-readable actions and current reference documentation. Details belong in `references/.md`, not in the main Skill. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b3cf221..bc5b418 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -6,6 +6,8 @@ **Both CI compilation and release packaging/publication are manual-only.** A push, PR or version tag starts neither workflow. The metadata-only PR Target workflow remains automatic. There is no auto-increment bot commit, and no workflow automatically dispatches another workflow. +Manual CI uses two small reporting jobs to bridge GitHub's PR-check event restriction: initialize the existing required commit statuses as pending, then publish results from actual jobs on the same SHA and run attempt. Builds have no status-write permission. Missing/skipped/failed evidence cannot become green, and superseded runs stop reporting. Branch protection still requires all five CI contexts from GitHub Actions plus PR Target; there is no administrator bypass. See [GitHub's required-check documentation](https://docs.github.com/en/pull-requests/how-tos/merge-and-close-pull-requests/troubleshooting-required-status-checks#checks-from-some-workflow-jobs-are-not-evaluated). + ## Prepare and promote Update the workspace version, npm manifest and lockfile, the three local Cargo.lock packages, Tauri version, Codex plugin version and Claude marketplace versions in one PR to `dev`. Independent built-in plugin versions are not the application version and should change only when that plugin changes. diff --git a/scripts/check-release-ci.mjs b/scripts/check-release-ci.mjs index 5d9cd85..04ffc4c 100644 --- a/scripts/check-release-ci.mjs +++ b/scripts/check-release-ci.mjs @@ -11,7 +11,7 @@ export const REQUIRED_JOBS = [ 'Rust Check (macos-latest)', ]; -export function selectManualCiRun(payload, sha, repository) { +export function latestManualCiRun(payload, sha, repository) { if (!Array.isArray(payload.workflow_runs)) throw new Error('Invalid CI workflow response'); const runs = payload.workflow_runs.filter(run => run.head_sha === sha && run.event === 'workflow_dispatch' @@ -22,8 +22,15 @@ export function selectManualCiRun(payload, sha, repository) { if (!latest) { throw new Error('Run CI manually on the exact release tag/commit before releasing'); } - if (!Number.isSafeInteger(latest.id) || latest.id <= 0 - || latest.status !== 'completed' || latest.conclusion !== 'success') { + if (!Number.isSafeInteger(latest.id) || latest.id <= 0) { + throw new Error('The latest manual CI run for this commit has not completed successfully'); + } + return latest; +} + +export function selectManualCiRun(payload, sha, repository) { + const latest = latestManualCiRun(payload, sha, repository); + if (latest.status !== 'completed' || latest.conclusion !== 'success') { throw new Error('The latest manual CI run for this commit has not completed successfully'); } return latest.id; diff --git a/scripts/report-ci-status.mjs b/scripts/report-ci-status.mjs new file mode 100644 index 0000000..d10fea8 --- /dev/null +++ b/scripts/report-ci-status.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +// workflow_dispatch job checks are not eligible PR checks. Publish commit +// statuses from actual GitHub job evidence, never from a maintainer override. +import { pathToFileURL } from 'node:url'; +import { latestManualCiRun, REQUIRED_JOBS } from './check-release-ci.mjs'; + +export async function reportCiStatuses(options, request) { + const { phase, repository, sha, runId, runAttempt } = options; + if (!['start', 'finish'].includes(phase) + || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository ?? '') + || !/^[a-f0-9]{40}$/.test(sha ?? '') + || !Number.isSafeInteger(runId) || runId <= 0 + || !Number.isSafeInteger(runAttempt) || runAttempt <= 0) { + throw new Error('Invalid CI status reporting context'); + } + const base = `/repos/${repository}`; + const runUrl = `https://github.com/${repository}/actions/runs/${runId}`; + const isCurrent = async () => { + const payload = await request('GET', `${base}/actions/workflows/ci.yml/runs?event=workflow_dispatch&head_sha=${sha}&per_page=100`); + const latest = latestManualCiRun(payload, sha, repository); + if (latest.id !== runId) return false; + const run = await request('GET', `${base}/actions/runs/${runId}`); + return run.run_attempt === runAttempt && run.head_sha === sha && run.conclusion !== 'cancelled' + && run.event === 'workflow_dispatch' && run.path === '.github/workflows/ci.yml' + && run.repository?.full_name === repository + && run.head_repository?.full_name === repository; + }; + if (!await isCurrent()) return { superseded: true, statuses: [] }; + + let jobs = []; + if (phase === 'finish') { + const payload = await request('GET', `${base}/actions/runs/${runId}/jobs?filter=latest&per_page=100`); + if (!Array.isArray(payload.jobs) || payload.total_count !== payload.jobs.length) { + throw new Error('Incomplete job evidence; refusing to publish CI success'); + } + jobs = payload.jobs; + } + const statuses = REQUIRED_JOBS.map(name => { + const matches = jobs.filter(job => job.name === name); + const job = matches.length === 1 ? matches[0] : undefined; + const passed = job?.status === 'completed' && job.conclusion === 'success'; + const state = phase === 'start' ? 'pending' : passed ? 'success' : 'failure'; + return { + context: name, state, target_url: runUrl, + description: `Manual CI #${runId}, attempt ${runAttempt}: ${phase === 'start' ? 'running' : job?.conclusion ?? 'missing job'}`, + }; + }); + // A superseded or cancelled run must not overwrite a newer run's statuses. + // Recheck before each write; GitHub's concurrency group cancels older runs. + for (const status of statuses) { + if (!await isCurrent()) return { superseded: true, statuses: [] }; + await request('POST', `${base}/statuses/${sha}`, status); + } + if (phase === 'finish' && statuses.some(status => status.state !== 'success')) { + throw new Error('Required manual CI jobs did not all succeed; see the linked run'); + } + return { superseded: false, statuses }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const token = process.env.GITHUB_TOKEN; + if (!token) throw new Error('GITHUB_TOKEN is required for CI status reporting'); + const request = async (method, path, body) => { + const response = await fetch(`https://api.github.com${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(15000), + }); + // Do not echo request headers or API error bodies into workflow logs. + if (!response.ok) throw new Error(`GitHub CI status API failed (${response.status})`); + return response.json(); + }; + const result = await reportCiStatuses({ + phase: process.argv[2], repository: process.env.GITHUB_REPOSITORY, + sha: process.env.GITHUB_SHA, runId: Number(process.env.GITHUB_RUN_ID), + runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT), + }, request); + console.log(result.superseded ? 'Inactive or superseded CI attempt; no further status updates.' + : `Published ${result.statuses.length} evidence-backed ${process.argv[2]} statuses.`); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/tests/ci-status.test.mjs b/scripts/tests/ci-status.test.mjs new file mode 100644 index 0000000..f4c5b5c --- /dev/null +++ b/scripts/tests/ci-status.test.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { reportCiStatuses } from '../report-ci-status.mjs'; +import { REQUIRED_JOBS } from '../check-release-ci.mjs'; + +const repository = 'owner/repo'; +const sha = 'a'.repeat(40); +const options = { phase: 'finish', repository, sha, runId: 42, runAttempt: 1 }; +function fixture(overrides = {}) { + const run = { + id: 42, head_sha: sha, event: 'workflow_dispatch', path: '.github/workflows/ci.yml', + repository: { full_name: repository }, head_repository: { full_name: repository }, + status: 'in_progress', conclusion: null, run_attempt: 1, + }; + const jobs = REQUIRED_JOBS.map(name => ({ name, status: 'completed', conclusion: 'success' })); + const state = { run, runs: [run], jobs, posts: [], ...overrides }; + state.request = async (method, path, body) => { + if (method === 'POST') { state.posts.push({ path, body }); return {}; } + if (path.includes('/jobs?')) return { jobs: state.jobs, total_count: state.total ?? state.jobs.length }; + if (path.includes('/workflows/')) return { workflow_runs: state.runs }; + if (path.endsWith('/runs/42')) return state.run; + throw new Error(`Unexpected test endpoint: ${path}`); + }; + return state; +} + +test('initialization marks all five required contexts pending, never green', async () => { + const state = fixture(); + await reportCiStatuses({ ...options, phase: 'start' }, state.request); + assert.equal(state.posts.length, REQUIRED_JOBS.length); + assert.deepEqual(state.posts.map(post => post.body.context), REQUIRED_JOBS); + assert.ok(state.posts.every(post => post.body.state === 'pending')); +}); + +test('only real successful jobs publish success for the exact tested commit', async () => { + const state = fixture(); + await reportCiStatuses(options, state.request); + assert.equal(state.posts.length, REQUIRED_JOBS.length); + assert.ok(state.posts.every(post => post.path === `/repos/${repository}/statuses/${sha}`)); + assert.ok(state.posts.every(post => post.body.state === 'success')); + assert.ok(state.posts.every(post => post.body.target_url.endsWith('/actions/runs/42'))); +}); + +test('failure, cancellation, skipping and incomplete jobs can never produce green', async () => { + for (const conclusion of ['failure', 'cancelled', 'skipped', 'neutral', 'timed_out', null]) { + const state = fixture(); + state.jobs[0].conclusion = conclusion; + await assert.rejects(reportCiStatuses(options, state.request), /did not all succeed/); + assert.equal(state.posts[0].body.state, 'failure'); + } + const state = fixture(); + state.jobs[0].status = 'in_progress'; + await assert.rejects(reportCiStatuses(options, state.request), /did not all succeed/); + assert.equal(state.posts[0].body.state, 'failure'); +}); + +test('missing and duplicate required job names fail closed', async () => { + for (const duplicate of [false, true]) { + const state = fixture(); + if (duplicate) state.jobs.push({ ...state.jobs[0] }); + else state.jobs.shift(); + await assert.rejects(reportCiStatuses(options, state.request), /did not all succeed/); + assert.equal(state.posts[0].body.state, 'failure'); + } +}); + +test('incomplete API pages cannot be treated as complete job evidence', async () => { + const state = fixture({ total: 100 }); + await assert.rejects(reportCiStatuses(options, state.request), /Incomplete job evidence/); + assert.equal(state.posts.length, 0); +}); + +test('older runs and stale rerun attempts do not overwrite current statuses', async () => { + const state = fixture(); + state.runs.push({ ...state.run, id: 43 }); + assert.equal((await reportCiStatuses(options, state.request)).superseded, true); + assert.equal(state.posts.length, 0); + const rerun = fixture(); + rerun.run.run_attempt = 2; + assert.equal((await reportCiStatuses(options, rerun.request)).superseded, true); + assert.equal(rerun.posts.length, 0); +}); + +test('a run superseded while fetching evidence cannot write a stale success', async () => { + const state = fixture(); + const request = async (...args) => { + const response = await state.request(...args); + if (args[1].includes('/jobs?')) state.runs.push({ ...state.run, id: 43 }); + return response; + }; + assert.equal((await reportCiStatuses(options, request)).superseded, true); + assert.equal(state.posts.length, 0); +}); + +test('a cancelled workflow cannot publish green even after its test jobs succeeded', async () => { + const state = fixture(); + state.run.conclusion = 'cancelled'; + assert.equal((await reportCiStatuses(options, state.request)).superseded, true); + assert.equal(state.posts.length, 0); +}); + +test('foreign repository, wrong SHA or automatic run evidence is rejected', async () => { + for (const override of [ + { event: 'push' }, { head_sha: 'b'.repeat(40) }, + { head_repository: { full_name: 'fork/repo' } }, + ]) { + const state = fixture(); + Object.assign(state.run, override); + await assert.rejects(reportCiStatuses(options, state.request), /Run CI manually/); + assert.equal(state.posts.length, 0); + } +}); + +test('invalid reporting context is rejected before any API call', async () => { + for (const override of [{ phase: 'force' }, { runId: 0 }, { runAttempt: 0 }, { sha: 'HEAD' }, { repository: 'bad/path/extra' }]) { + await assert.rejects(reportCiStatuses({ ...options, ...override }, () => { + assert.fail('Invalid context must not reach the API'); + }), /Invalid CI status/); + } +}); diff --git a/scripts/tests/workflows.test.mjs b/scripts/tests/workflows.test.mjs index f466923..7a4e459 100644 --- a/scripts/tests/workflows.test.mjs +++ b/scripts/tests/workflows.test.mjs @@ -79,3 +79,16 @@ test('release is restricted to main and defaults to an unpublished draft', () => assert.match(text, /if \[\[ "\$PUBLISH_RELEASE" == 'true' \]\]; then\s+gh release edit[^\n]*--draft=false --latest/); assert.doesNotMatch(text, /inputs\.tag \|\| github\.ref_name/); }); + +test('manual CI reports evidence-backed statuses without granting write access to builds', () => { + const text = readFileSync(new URL('../../.github/workflows/ci.yml', import.meta.url), 'utf8').replaceAll('\r\n', '\n'); + for (const id of ['check-frontend', 'check-backend', 'check-clippy']) { + const section = text.split(` ${id}:\n`)[1]?.split(/^ [a-z-]+:\n/m)[0]; + assert.ok(section, id); + assert.match(section, /needs: status-start/); + assert.doesNotMatch(section, /statuses: write/); + } + assert.match(text, /needs: \[status-start, check-frontend, check-backend, check-clippy\]\s+if: always\(\)/); + assert.match(text, /node scripts\/report-ci-status\.mjs start/); + assert.match(text, /node scripts\/report-ci-status\.mjs finish/); +});