chore: import upstream snapshot with attribution
CI / Change detection (push) Has been cancelled
CI / Version drift (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Workflow RLM cache (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / npm wrapper smoke (push) Has been cancelled
CI / Mobile runtime smoke (push) Has been cancelled
CI / Workflow lint (push) Has been cancelled
CI / Documentation (push) Has been cancelled
Web Frontend / Lint & Type Check (push) Failing after 1s
Auto-close harvested PRs / close (push) Failing after 1s
cargo-deny / cargo-deny (bans licenses sources) (push) Failing after 1s
Security audit / cargo-audit (push) Failing after 1s
Sync to CNB / sync (push) Failing after 1s
cargo-deny / cargo-deny (advisories) (push) Failing after 3s
Web Frontend / Deploy to Cloudflare (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:23 +08:00
commit d68f003000
1008 changed files with 594824 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
name: Approve gated contributor
on:
issue_comment:
types: [created]
permissions:
contents: write
issues: write
pull-requests: write
concurrency:
group: contribution-gate-approval
cancel-in-progress: false
jobs:
approve:
runs-on: ubuntu-latest
steps:
- name: Open allowlist update PR
uses: actions/github-script@v9
with:
script: |
const comment = context.payload.comment;
const issue = context.payload.issue;
const owner = context.repo.owner;
const repo = context.repo.repo;
const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
const command = (comment.body || '').trim().toLowerCase();
const scopeByCommand = new Map([
['/lgtm', 'pr'],
['lgtm', 'pr'],
['/lgtmi', 'issue'],
['lgtmi', 'issue'],
]);
const scope = scopeByCommand.get(command);
if (!scope) return;
if (!privileged.has(comment.author_association)) return;
if (scope === 'pr' && !issue.pull_request) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: '`/lgtm` grants PR access and must be used on a pull request. Use `/lgtmi` to grant issue access.',
});
return;
}
if (scope === 'issue' && issue.pull_request) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: '`/lgtmi` grants issue access and must be used on an issue. Use `/lgtm` to grant PR access.',
});
return;
}
const path = '.github/APPROVED_CONTRIBUTORS';
const targetLogin = issue.user.login;
const normalizedLogin = targetLogin.toLowerCase();
const entry = `${scope}:${normalizedLogin}`;
const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor';
const defaultContent = [
'# Scoped contribution-gate allowlist.',
'#',
'# Maintainers and collaborators bypass the gate automatically. Use this file',
'# for external contributors who are allowed through the automated front door.',
'# Seed active contributors here before switching the gate workflows to enforce mode.',
'#',
'# Supported entries:',
'# pr:username',
'# issue:username',
'# all:username',
'',
].join('\n');
function parseAllowlist(content) {
return new Set(
content
.split(/\r?\n/)
.map(line => line.replace(/#.*/, '').trim().toLowerCase())
.filter(Boolean)
);
}
const { data: repoData } = await github.rest.repos.get({ owner, repo });
const defaultBranch = repoData.default_branch;
const { data: baseRef } = await github.rest.git.getRef({
owner,
repo,
ref: `heads/${defaultBranch}`,
});
const baseSha = baseRef.object.sha;
const { data: baseCommit } = await github.rest.git.getCommit({
owner,
repo,
commit_sha: baseSha,
});
let content = defaultContent;
try {
const { data } = await github.rest.repos.getContent({
owner,
repo,
path,
ref: defaultBranch,
});
if (!Array.isArray(data) && data.type === 'file') {
content = Buffer.from(data.content, data.encoding || 'base64').toString('utf8');
}
} catch (error) {
if (error.status !== 404) throw error;
}
const existing = parseAllowlist(content);
if (existing.has(entry) || existing.has(`all:${normalizedLogin}`)) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: `@${targetLogin} is already approved for ${scope} contributions in \`${path}\`.`,
});
return;
}
const openPrs = [];
for (let page = 1; ; page++) {
const { data: pagePrs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
per_page: 100,
page,
});
openPrs.push(...pagePrs);
if (pagePrs.length < 100) break;
}
const repoFullName = `${owner}/${repo}`.toLowerCase();
const pendingPr = openPrs.find(openPr => {
const sameRepo = (openPr.head?.repo?.full_name || '').toLowerCase() === repoFullName;
const body = openPr.body || '';
return sameRepo && body.includes(`Adds \`${entry}\` to \`${path}\`.`);
});
if (pendingPr) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: `@${targetLogin} already has a pending allowlist update PR for ${scope} contributions: ${pendingPr.html_url}`,
});
return;
}
const nextContent = `${content.trimEnd()}\n${entry}\n`;
const { data: blob } = await github.rest.git.createBlob({
owner,
repo,
content: nextContent,
encoding: 'utf-8',
});
const { data: tree } = await github.rest.git.createTree({
owner,
repo,
base_tree: baseCommit.tree.sha,
tree: [
{
path,
mode: '100644',
type: 'blob',
sha: blob.sha,
},
],
});
const branchName = `contribution-gate/${scope}-${branchSlug}-${Date.now()}`;
await github.rest.git.createRef({
owner,
repo,
ref: `refs/heads/${branchName}`,
sha: baseSha,
});
const { data: commit } = await github.rest.git.createCommit({
owner,
repo,
message: `chore: approve @${targetLogin} for ${scope} contributions`,
tree: tree.sha,
parents: [baseSha],
});
await github.rest.git.updateRef({
owner,
repo,
ref: `heads/${branchName}`,
sha: commit.sha,
});
const { data: pr } = await github.rest.pulls.create({
owner,
repo,
title: `chore: approve @${targetLogin} for ${scope} contributions`,
head: branchName,
base: defaultBranch,
body: [
`Adds \`${entry}\` to \`${path}\`.`,
'',
`Requested by @${comment.user.login} in #${issue.number}.`,
].join('\n'),
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: `Created allowlist update PR: ${pr.html_url}`,
});
+153
View File
@@ -0,0 +1,153 @@
name: Auto-close harvested PRs
# When a commit on main contains a "Harvested from PR #N" line in its
# message, close PR #N with a templated thank-you that links back to
# the merged commit. Solves the long-standing problem where contributor
# PRs whose code lands via maintainer cherry-pick stay open and
# `CONFLICTING` forever, even though their fix is credited in the
# CHANGELOG.
#
# The expected commit-message convention is documented in
# CONTRIBUTING.md. Two patterns are recognised:
#
# * `Harvested from PR #1234 by @username` (preferred)
# * `harvested from #1234` (case-insensitive fallback)
#
# The first match's PR number is closed; multiple PRs can be closed
# per commit by repeating the line. The match runs on the commit
# body only, not on the subject line, so the subject can describe
# the change naturally without baking a number into it.
on:
push:
branches: [main]
permissions:
contents: read
pull-requests: write
issues: write
# Only one auto-close run at a time so two near-simultaneous main
# pushes can't both try to close the same PR (the second would just
# fail with "Pull request is already closed", harmless but noisy).
concurrency:
group: auto-close-harvested
cancel-in-progress: false
jobs:
close:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# We need at least the commits that this push introduced.
# fetch-depth: 0 is the simplest correct option; the
# alternative (fetching just `before..after`) is fragile
# when force-pushes happen.
fetch-depth: 0
- name: Close PRs referenced by harvested-from lines
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.event.after }}
shell: bash
run: |
set -euo pipefail
# The first push to a fresh branch has BEFORE_SHA = 0000…0000.
# In that case fall back to the latest commit only — we don't
# want to scan the entire history.
if [[ "${BEFORE_SHA}" == "0000000000000000000000000000000000000000" || -z "${BEFORE_SHA:-}" ]]; then
RANGE="${AFTER_SHA}"
RANGE_ARGS=("-1" "${AFTER_SHA}")
else
RANGE="${BEFORE_SHA}..${AFTER_SHA}"
RANGE_ARGS=("${RANGE}")
fi
echo "Scanning commit range: ${RANGE}"
# `git log --format=%H%n%B%n--END--` separates commits with a
# sentinel so multi-line bodies don't get mangled.
mapfile -t commits < <(git log "${RANGE_ARGS[@]}" --format="%H")
if [[ ${#commits[@]} -eq 0 ]]; then
echo "No commits in range; nothing to do."
exit 0
fi
declare -A processed_prs=()
for sha in "${commits[@]}"; do
body="$(git log -1 --format=%B "${sha}")"
# Two patterns, both case-insensitive on the keyword:
# "Harvested from PR #1234 by @username" (preferred form)
# "harvested from #1234" (short form)
mapfile -t pr_numbers < <(
printf '%s\n' "${body}" \
| grep -oiE 'harvested from (pr )?#[0-9]+' \
| grep -oE '#[0-9]+' \
| tr -d '#' \
| sort -u || true
)
if [[ ${#pr_numbers[@]} -eq 0 ]]; then
continue
fi
short_sha="${sha:0:12}"
subject="$(git log -1 --format=%s "${sha}")"
for pr in "${pr_numbers[@]}"; do
key="${pr}-${sha}"
if [[ -n "${processed_prs[${key}]:-}" ]]; then
continue
fi
processed_prs[${key}]=1
# Idempotency: skip if the PR is already closed.
state="$(gh pr view "${pr}" --json state --jq .state 2>/dev/null || echo "MISSING")"
if [[ "${state}" == "CLOSED" || "${state}" == "MERGED" ]]; then
echo "PR #${pr} is already ${state}; skipping."
continue
fi
if [[ "${state}" == "MISSING" ]]; then
echo "::warning::PR #${pr} not found or inaccessible; skipping."
continue
fi
author="$(gh pr view "${pr}" --json author --jq '.author.login' 2>/dev/null || echo "")"
greeting="Hi"
if [[ -n "${author}" ]]; then
greeting="Thanks @${author}"
fi
# NOTE: this block intentionally avoids `<<EOF` heredocs.
# YAML's `|` block scalar requires consistent indentation,
# but heredoc bodies have to start at column 0 — those two
# constraints can't coexist in the same file. We assemble
# the body with `printf` + `\n` so every line of the
# message lives at the same indent as the surrounding
# shell code.
commit_url="https://github.com/${GITHUB_REPOSITORY}/commit/${sha}"
contributing_url="https://github.com/${GITHUB_REPOSITORY}/blob/main/CONTRIBUTING.md"
body_text="$(printf '%s\n' \
"${greeting} — your contribution landed in [\`${short_sha}\`](${commit_url}) on \`main\`:" \
"" \
"> ${subject}" \
"" \
"Closing this PR now that the code is on \`main\`. Credit lives in the commit message and (where applicable) the \`CHANGELOG.md\` entry for the next release. Apologies for not closing this at the time of the merge — the auto-close workflow is new in v0.8.31." \
"" \
"If you want to land more work and would prefer your future PRs merge cleanly without a harvest step, the [\`CONTRIBUTING.md\`](${contributing_url}) doc has a short note on what makes a contribution mergeable as-is." \
)"
echo "Closing PR #${pr} (harvested in ${short_sha})"
if ! gh pr close "${pr}" \
--repo "${GITHUB_REPOSITORY}" \
--comment "${body_text}"; then
echo "::warning::Failed to close PR #${pr}; continuing"
fi
done
done
echo "Auto-close pass complete."
+129
View File
@@ -0,0 +1,129 @@
name: Create release tag
# Manual helper that creates `vX.Y.Z` from the current `main` workspace
# version after the release source is frozen. This is intentionally not
# triggered by every version bump on `main`: release prep can land before the
# same-version issue/PR queue is truly empty, and an eager tag leaves later
# same-version work outside the public release anchor.
#
# IMPORTANT: tag pushes signed by the default `GITHUB_TOKEN` do NOT trigger
# downstream `on: push: tags` workflows (GitHub Actions safety rule). For
# this auto-tag flow to actually fire `release.yml`, store a PAT (or
# fine-grained token) with `contents: write` on this repo as the
# `RELEASE_TAG_PAT` secret. Without it, the tag is created but `release.yml`
# does NOT run automatically; run `release.yml` manually for the version.
on:
workflow_dispatch:
permissions:
contents: write
concurrency:
group: auto-tag-${{ github.ref_name }}
cancel-in-progress: false
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
# Prefer PAT so the resulting tag push triggers release.yml.
# Falls back to GITHUB_TOKEN, which will tag but NOT trigger.
token: ${{ secrets.RELEASE_TAG_PAT || github.token }}
- name: Require main dispatch
run: |
if [ "${GITHUB_REF_NAME}" != "main" ]; then
echo "::error::Create release tags from main only; rerun this workflow with ref 'main'." >&2
exit 1
fi
- name: Read workspace version
id: ver
run: |
v="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')"
if [ -z "$v" ]; then
echo "::error::Could not parse workspace version from Cargo.toml" >&2
exit 1
fi
if ! echo "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Workspace version '$v' is not valid semver (expected X.Y.Z)" >&2
exit 1
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "tag=v$v" >> "$GITHUB_OUTPUT"
echo "Workspace version: $v"
- name: Check whether tag already exists
id: check
env:
TAG: ${{ steps.ver.outputs.tag }}
run: |
git fetch --tags --quiet
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null \
|| git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} already exists; nothing to do."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} does not exist; will create."
fi
- name: Verify version consistency
if: steps.check.outputs.exists == 'false'
run: |
./scripts/release/check-versions.sh || {
echo "::error::Version consistency check failed. Aborting tag creation." >&2
exit 1
}
- name: Create and push tag
id: create
if: steps.check.outputs.exists == 'false'
env:
TAG: ${{ steps.ver.outputs.tag }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch --tags --quiet
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null \
|| git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
echo "pushed=false" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} already exists after refresh; nothing to do."
exit 0
fi
git tag "${TAG}"
max_retries=3
retry_count=0
while [ "${retry_count}" -lt "${max_retries}" ]; do
if git push origin "${TAG}"; then
echo "pushed=true" >> "$GITHUB_OUTPUT"
echo "Pushed ${TAG}. release.yml should now run (requires RELEASE_TAG_PAT for trigger)."
exit 0
fi
if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
echo "pushed=false" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} appeared during push; treating as already handled."
exit 0
fi
retry_count=$((retry_count + 1))
if [ "${retry_count}" -lt "${max_retries}" ]; then
echo "Push attempt ${retry_count} failed; retrying in 10s..."
sleep 10
fi
done
echo "::error::Failed to push tag ${TAG} after ${max_retries} attempts." >&2
exit 1
- name: Warn if PAT missing
if: steps.create.outputs.pushed == 'true'
env:
HAS_PAT: ${{ secrets.RELEASE_TAG_PAT != '' }}
run: |
if [ "${HAS_PAT}" != "true" ]; then
echo "::warning::RELEASE_TAG_PAT secret is not set. The tag was pushed using GITHUB_TOKEN, which does NOT trigger release.yml. Run 'gh workflow run release.yml --ref main -f version=${{ steps.ver.outputs.version }}' after confirming the tag points at the intended main commit."
fi
+36
View File
@@ -0,0 +1,36 @@
name: cargo-deny
permissions: {}
on:
pull_request:
paths:
- '.github/workflows/cargo-deny.yml'
- '**/Cargo.toml'
- '**/Cargo.lock'
- 'deny.toml'
push:
branches: [main, master]
schedule:
# Run weekly on Monday at 06:31 UTC
- cron: '31 6 * * 1'
env:
CARGO_TERM_COLOR: always
jobs:
deny:
name: cargo-deny
runs-on: ubuntu-latest
strategy:
matrix:
checks:
- advisories
- bans licenses sources
# Prevent sudden announcement of a new advisory from failing CI
continue-on-error: ${{ matrix.checks == 'advisories' }}
steps:
- uses: actions/checkout@v7
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check ${{ matrix.checks }}
+470
View File
@@ -0,0 +1,470 @@
name: CI
on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
schedule:
- cron: '31 6 * * 1'
permissions:
contents: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
jobs:
changes:
name: Change detection
runs-on: ubuntu-latest
outputs:
heavy: ${{ steps.detect.outputs.heavy }}
workflow: ${{ steps.detect.outputs.workflow }}
mobile: ${{ steps.detect.outputs.mobile }}
actions: ${{ steps.detect.outputs.actions }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Detect executable changes
id: detect
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "schedule" || "${EVENT_NAME}" == "workflow_dispatch" ]]; then
echo "heavy=true" >> "${GITHUB_OUTPUT}"
echo "workflow=true" >> "${GITHUB_OUTPUT}"
echo "mobile=true" >> "${GITHUB_OUTPUT}"
echo "actions=true" >> "${GITHUB_OUTPUT}"
exit 0
fi
base=""
if [[ "${EVENT_NAME}" == "pull_request" && -n "${BASE_REF}" ]]; then
git fetch --no-tags origin "${BASE_REF}:refs/remotes/origin/${BASE_REF}" --depth=1
base="origin/${BASE_REF}"
elif [[ -n "${BEFORE_SHA}" && "${BEFORE_SHA}" != "0000000000000000000000000000000000000000" ]]; then
base="${BEFORE_SHA}"
fi
if [[ -z "${base}" ]]; then
echo "heavy=true" >> "${GITHUB_OUTPUT}"
echo "workflow=true" >> "${GITHUB_OUTPUT}"
echo "mobile=true" >> "${GITHUB_OUTPUT}"
echo "actions=true" >> "${GITHUB_OUTPUT}"
exit 0
fi
mapfile -t changed < <(git diff --name-only "${base}" "${GITHUB_SHA}" | sort)
heavy=false
workflow=false
mobile=false
actions=false
for path in "${changed[@]}"; do
# Heavy classification. ORDER MATTERS: must-stay-heavy inputs are
# matched BEFORE any light entry so a script that only a
# heavy-gated job exercises can never be misclassified as light.
# Anything unrecognized falls through to the default-heavy `*)`
# arm (fail-safe default-heavy). Light-classified scripts below
# are either never run by CI (v0867-setup-qa.sh) or exercised by
# ALWAYS-on jobs/steps that run regardless of `heavy`
# (check-versions.sh / check-ohos-deps.sh via Version drift,
# check-coauthor-trailers.py via Lint), so no coverage is lost.
case "${path}" in
scripts/release/npm-wrapper-smoke.js|scripts/mobile-smoke.sh|scripts/check-provider-registry.py)
heavy=true
;;
docs/*|*.md|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|.github/workflows/auto-tag.yml|.github/workflows/stale.yml|.github/workflows/triage.yml|scripts/v0867-setup-qa.sh|scripts/release/check-versions.sh|scripts/release/check-ohos-deps.sh|scripts/check-coauthor-trailers.py)
;;
*)
heavy=true
;;
esac
case "${path}" in
crates/workflow/*|workflows/rlm_cache_change.star|.github/workflows/ci.yml)
workflow=true
;;
esac
# Mobile runtime surface: the `codewhale-tui serve --mobile`
# HTTP/SSE stack that scripts/mobile-smoke.sh exercises. Pull
# requests run the smoke only when one of these changes; every
# push to main still runs it unconditionally as the pre-release
# safety net for anything this filter misses.
case "${path}" in
crates/app-server/*|crates/tui/src/runtime_api*|crates/tui/src/runtime_mobile.html|crates/tui/src/runtime_threads*|crates/tui/src/main.rs|scripts/mobile-smoke.sh|.github/workflows/ci.yml|Cargo.lock|Cargo.toml)
mobile=true
;;
esac
case "${path}" in
.github/workflows/*|.github/actionlint.yml)
actions=true
;;
esac
done
echo "heavy=${heavy}" >> "${GITHUB_OUTPUT}"
echo "workflow=${workflow}" >> "${GITHUB_OUTPUT}"
echo "mobile=${mobile}" >> "${GITHUB_OUTPUT}"
echo "actions=${actions}" >> "${GITHUB_OUTPUT}"
echo "Heavy Rust CI required: ${heavy}"
echo "Workflow RLM cache CI required: ${workflow}"
echo "Mobile runtime smoke required (PRs): ${mobile}"
echo "Workflow lint required: ${actions}"
printf 'Changed files:\n'
printf ' %s\n' "${changed[@]}"
versions:
name: Version drift
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v6
with:
node-version: 20
- name: Check version drift
run: ./scripts/release/check-versions.sh
- name: Check OHOS dependency graph
run: ./scripts/release/check-ohos-deps.sh
lint:
name: Lint
needs: changes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@master
if: needs.changes.outputs.heavy == 'true'
with:
toolchain: stable
components: rustfmt, clippy
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
# Cache bootstrap failures (e.g. GitHub 504s fetching the sccache
# binary) degrade to an uncached build instead of failing product CI.
continue-on-error: true
if: needs.changes.outputs.heavy == 'true'
- name: Enable sccache
if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- name: Install Linux system dependencies
if: needs.changes.outputs.heavy == 'true'
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
if: needs.changes.outputs.heavy == 'true'
with:
cache-bin: false
# PRs restore the cache seeded by main but skip the expensive
# post-job save; sccache covers PR-specific compilation deltas.
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check formatting
if: needs.changes.outputs.heavy == 'true'
run: cargo fmt --all -- --check
- name: Run clippy
if: needs.changes.outputs.heavy == 'true'
run: |
cargo clippy --workspace --all-features --locked -- \
-D warnings \
-A clippy::uninlined_format_args \
-A clippy::too_many_arguments \
-A clippy::unnecessary_map_or \
-A clippy::collapsible_if \
-A clippy::assertions_on_constants
- name: sccache stats
if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success'
continue-on-error: true
shell: bash
run: sccache --show-stats
- name: Check provider registry drift
if: needs.changes.outputs.heavy == 'true'
run: python3 scripts/check-provider-registry.py
- name: Check README translations stay in sync
if: github.event_name != 'schedule'
run: python3 scripts/check-readme-translations.py
- name: Check harvested contributor credit
if: github.event_name != 'schedule'
shell: bash
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
git fetch --no-tags origin "${{ github.base_ref }}"
RANGE="origin/${{ github.base_ref }}..HEAD"
elif [[ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then
RANGE="${{ github.event.before }}..${{ github.sha }}"
else
RANGE="HEAD~1..HEAD"
fi
python3 scripts/check-coauthor-trailers.py \
--author-map .github/AUTHOR_MAP \
--range "$RANGE" \
--check-authors
- name: Skip Rust lint for light change
if: needs.changes.outputs.heavy != 'true'
run: echo "No executable Rust changes detected; preserving required Lint context."
- name: Linux clippy location
if: needs.changes.outputs.heavy == 'true'
run: echo "Linux clippy/test gates run on CNB for mirrored fix/*, rebrand/*, work/v*, and main branches."
workflow-rlm-cache:
name: Workflow RLM cache
needs: changes
if: needs.changes.outputs.workflow == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Run RLM cache workflow mock/replay tests
run: cargo test -p codewhale-workflow --locked rlm_cache_change
test:
name: Test
needs: changes
# Required contexts "Test (ubuntu-latest)" / "Test (macos-latest)" /
# "Test (windows-latest)" derive from job name + matrix.os and are
# independent of runs-on. For light changes the macOS/Windows legs only
# echo a skip line, so run them on ubuntu instead of queueing for scarce
# macOS/Windows runners. Heavy changes use the real matrix OS as before.
# The ternary is safe: matrix.os is always a non-empty literal, so
# runs-on can never evaluate to empty.
runs-on: ${{ needs.changes.outputs.heavy == 'true' && matrix.os || 'ubuntu-latest' }}
strategy:
matrix:
# Linux workspace tests moved to CNB; GitHub keeps the platform
# coverage CNB cannot provide.
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Skip tests for light change
if: needs.changes.outputs.heavy != 'true'
run: echo "No executable Rust changes detected; preserving required Test context."
- uses: actions/checkout@v7
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
- uses: dtolnay/rust-toolchain@stable
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
- name: Enable sccache
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' && steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- uses: Swatinem/rust-cache@v2
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
with:
cache-bin: false
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Run tests
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
run: cargo test --workspace --all-features --locked
- name: Lockfile drift guard
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
run: git diff --exit-code -- Cargo.lock
- name: Run Offline Eval Harness
# The eval harness is OS-independent prompt/composition checking;
# running it once (on the faster macOS leg, warm from the test build)
# instead of once per desktop OS keeps the coverage while taking
# ~2min off the Windows critical path.
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
run: cargo run -p codewhale-tui --all-features -- eval
- name: sccache stats
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' && steps.sccache.outcome == 'success'
continue-on-error: true
shell: bash
run: sccache --show-stats
- name: Linux test location
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest'
run: echo "Linux workspace tests run on CNB for mirrored first-party branches."
npm-wrapper-smoke:
name: npm wrapper smoke
needs: changes
if: github.event_name != 'schedule'
# Same ternary rationale as the Test job: light legs only echo, so keep
# them off macOS/Windows runners. On pull_request the matrix is
# ubuntu-only, so the required "npm wrapper smoke (ubuntu-latest)"
# context is unaffected.
runs-on: ${{ needs.changes.outputs.heavy == 'true' && matrix.os || 'ubuntu-latest' }}
strategy:
matrix:
os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || '["ubuntu-latest","macos-latest","windows-latest"]') }}
steps:
- name: Skip npm wrapper smoke for light change
if: needs.changes.outputs.heavy != 'true'
run: echo "No executable Rust changes detected; preserving required npm wrapper smoke context."
- uses: actions/checkout@v7
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
- uses: dtolnay/rust-toolchain@stable
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
- name: Enable sccache
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' && steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- uses: actions/setup-node@v6
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
with:
node-version: 20
- uses: Swatinem/rust-cache@v2
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
with:
cache-bin: false
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Build wrapper binaries
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
# The smoke validates wrapper install/delegation plumbing, not
# codegen quality, so skip fat LTO + codegen-units=1 for a much
# cheaper release build. Shipped binaries keep the real profile via
# the Release workflow.
env:
CARGO_PROFILE_RELEASE_LTO: 'off'
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
run: cargo build --release --locked -p codewhale-cli -p codewhale-tui
- name: Smoke wrapper install and delegated entrypoints
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
run: node scripts/release/npm-wrapper-smoke.js
- name: sccache stats
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' && steps.sccache.outcome == 'success'
continue-on-error: true
shell: bash
run: sccache --show-stats
- name: Linux smoke location
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest'
run: echo "Linux npm wrapper smoke runs on CNB for mirrored first-party branches."
mobile-smoke:
name: Mobile runtime smoke
needs: changes
# Not a required PR context. Pull requests run it only when the mobile
# runtime surface changed (see the `mobile` filter above); every push to
# main runs it unconditionally as the pre-release safety net.
if: >-
github.event_name != 'schedule' &&
needs.changes.outputs.heavy == 'true' &&
(github.event_name != 'pull_request' || needs.changes.outputs.mobile == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- name: Install Linux system dependencies
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Run mobile smoke tests
# The smoke exercises HTTP/SSE runtime behaviour, not codegen
# quality; skipping fat LTO + codegen-units=1 cuts the in-script
# release build from ~12min to a fraction of that.
env:
CARGO_PROFILE_RELEASE_LTO: 'off'
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
run: ./scripts/mobile-smoke.sh
- name: sccache stats
if: steps.sccache.outcome == 'success'
continue-on-error: true
shell: bash
run: sccache --show-stats
actionlint:
name: Workflow lint
needs: changes
if: needs.changes.outputs.actions == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Run actionlint
uses: docker://rhysd/actionlint:1.7.7
with:
# SC2129 (grouped redirects) is style-only and endemic to the
# existing GITHUB_ENV/GITHUB_OUTPUT append pattern; SC2221/SC2222
# flag the long-standing `*.md` glob shadowing the PR-template
# entry in change detection, which is intentional.
args: -color -ignore SC2129 -ignore SC2221 -ignore SC2222
# Check documentation builds without warnings
docs:
name: Documentation
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
- name: Build docs
run: cargo doc --workspace --no-deps
env:
RUSTDOCFLAGS: -Dwarnings
+81
View File
@@ -0,0 +1,81 @@
name: Claude PR Review
# Advisory AI code review by Claude (anthropics/claude-code-action) on every
# non-draft PR. CODEOWNERS (@Hmbown) stays the human owner — this review posts
# alongside it, it does not replace approval.
#
# Setup: add a CLAUDE_CODE_OAUTH_TOKEN repository secret
# 1. Run `claude setup-token` locally (Pro/Max subscription) to mint a token.
# 2. Settings -> Secrets and variables -> Actions -> New repository secret.
# Until the secret exists the job no-ops with a notice (stays green), so this
# workflow is safe to merge before the token is configured.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: [master, main]
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
claude-review:
name: Claude review
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
env:
HAS_OAUTH: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }}
permissions:
contents: read
pull-requests: write
id-token: write
steps:
- name: Skip when token is unset
if: env.HAS_OAUTH != 'true'
run: echo "::notice::CLAUDE_CODE_OAUTH_TOKEN is not set — skipping Claude review. Add the secret to enable it."
- name: Checkout repository
if: env.HAS_OAUTH == 'true'
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Claude code review
if: env.HAS_OAUTH == 'true'
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
track_progress: true
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
You are reviewing a pull request against CodeWhale, a Rust workspace
(an agentic coding TUI/runtime). The PR branch is already checked out
in the current working directory.
Review the diff and report findings in this priority order:
1. Correctness bugs: logic errors, panics, unwrap/expect on fallible
paths, race conditions, incorrect error handling, off-by-one, and
non-exhaustive matches that could break compilation.
2. Provider/model/route safety (v0.8.65 EPIC #2608 invariant): a
provider-prefixed model string (e.g. `deepseek-ai/`, `deepseek/`,
`anthropic/`, `openai/`, `qwen/`) is a wire id or namespace hint,
never proof of provider selection. Flag any code that infers a
provider/model switch from such a prefix or from freeform prompt
text rather than from explicit user choice, config, Fleet policy,
capability requirements, or fallback policy.
3. Reuse and simplification: duplicated logic, dead code, needless
allocation/cloning, or reimplementing something the workspace
already provides.
4. Tests: missing coverage for new behavior and edge cases.
5. Security: secret handling, shell/exec policy, input validation.
Be specific and concise. Use inline comments for line-specific issues
and one top-level comment for the summary. Note genuinely good choices
briefly. Do not nitpick style that `cargo fmt` / `clippy` already
enforce.
claude_args: |
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(git log:*),Bash(git diff:*)"
+56
View File
@@ -0,0 +1,56 @@
name: DCO
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
jobs:
dco:
name: Check Signed-off-by
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Report missing Signed-off-by (dry-run advisory)
shell: bash
run: |
set -euo pipefail
base_rev=$(git merge-base origin/${{ github.base_ref }} HEAD 2>/dev/null || echo "HEAD~1")
echo "✅ merge-base: $base_rev"
echo ""
bad=()
while IFS= read -r commit; do
msg=$(git log --format=%B -n1 "$commit")
if ! echo "$msg" | grep -qi "^Signed-off-by:"; then
bad+=("$commit")
fi
done < <(git rev-list "${base_rev}..HEAD")
if [ ${#bad[@]} -eq 0 ]; then
echo "✅ All commits have Signed-off-by."
exit 0
fi
echo "⚠️ Advisory — the following commit(s) are missing Signed-off-by:"
for c in "${bad[@]}"; do
echo " • $c $(git log --format=%s -n1 "$c")"
done
echo ""
echo "This check is advisory and does not block merging."
echo "Please amend commits with:"
echo " git commit --amend -s"
echo "See CONTRIBUTING.md for details."
echo ""
echo "::warning title=DCO: missing Signed-off-by::Some commits are missing Signed-off-by — amend with git commit --amend -s"
# Dry-run: always pass, but surface the warning
exit 0
+84
View File
@@ -0,0 +1,84 @@
name: Contribution intake - issues
on:
issues:
types: [opened, reopened]
permissions:
contents: read
issues: write
jobs:
gate:
runs-on: ubuntu-latest
steps:
- name: Welcome new external issue reporters
uses: actions/github-script@v9
with:
script: |
const issue = context.payload.issue;
const owner = context.repo.owner;
const repo = context.repo.repo;
const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
if (privileged.has(issue.author_association)) return;
if (issue.user.login === 'github-actions[bot]') return;
function parseAllowlist(content) {
return new Set(
content
.split(/\r?\n/)
.map(line => line.replace(/#.*/, '').trim().toLowerCase())
.filter(Boolean)
);
}
async function readAllowlist() {
try {
const { data } = await github.rest.repos.getContent({
owner,
repo,
path: '.github/APPROVED_CONTRIBUTORS',
ref: context.payload.repository.default_branch,
});
if (Array.isArray(data) || data.type !== 'file') return new Set();
return parseAllowlist(
Buffer.from(data.content, data.encoding || 'base64').toString('utf8')
);
} catch (error) {
if (error.status === 404) return new Set();
throw error;
}
}
const allowlist = await readAllowlist();
const login = issue.user.login.toLowerCase();
if (
allowlist.has(`all:${login}`) ||
allowlist.has(`issue:${login}`)
) {
return;
}
const marker = '<!-- codewhale-issue-intake -->';
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issue.number,
per_page: 100,
});
if (comments.some(comment => (comment.body || '').includes(marker))) return;
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: [
marker,
`Thanks @${issue.user.login} for the report.`,
'',
'This issue is staying open for maintainer triage. CodeWhale gets better because people bring us real edge cases from real machines, providers, regions, and workflows.',
'',
'If you can add a reproduction, logs, version output, screenshots, or the provider/model involved, that makes it much easier for us to verify and harvest the fix. Maintainers may comment `/lgtmi` to mark recurring issue reporters as approved so this intake note is skipped next time.',
].join('\n'),
});
+159
View File
@@ -0,0 +1,159 @@
name: Nightly
# Scheduled nightly artifact build. This used to run on every push to main,
# rebuilding five release targets per merge; PR CI already builds/tests the
# same commits on macOS/Windows and CNB covers Linux, so a daily cadence
# keeps the artifact stream fresh without burning ~28 runner-minutes per
# merge. Use workflow_dispatch for an on-demand build.
on:
schedule:
- cron: '17 9 * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nightly-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
DEEPSEEK_BUILD_SHA: ${{ github.sha }}
jobs:
build:
name: Build ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
platform: linux-x64
cli_binary: codewhale
tui_binary: codewhale-tui
cli_artifact: codewhale-linux-x64
tui_artifact: codewhale-tui-linux-x64
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
platform: linux-arm64
cli_binary: codewhale
tui_binary: codewhale-tui
cli_artifact: codewhale-linux-arm64
tui_artifact: codewhale-tui-linux-arm64
- os: macos-latest
target: x86_64-apple-darwin
platform: macos-x64
cli_binary: codewhale
tui_binary: codewhale-tui
cli_artifact: codewhale-macos-x64
tui_artifact: codewhale-tui-macos-x64
- os: macos-latest
target: aarch64-apple-darwin
platform: macos-arm64
cli_binary: codewhale
tui_binary: codewhale-tui
cli_artifact: codewhale-macos-arm64
tui_artifact: codewhale-tui-macos-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
platform: windows-x64
cli_binary: codewhale.exe
tui_binary: codewhale-tui.exe
cli_artifact: codewhale-windows-x64.exe
tui_artifact: codewhale-tui-windows-x64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
targets: ${{ matrix.target }}
- name: Install Rust target
run: rustup target add --toolchain stable ${{ matrix.target }}
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- name: Build
shell: bash
# Nightly artifacts are disposable smoke binaries (14-day retention),
# so skip fat LTO + codegen-units=1; tagged releases keep the full
# optimized profile via the Release workflow.
env:
CARGO_PROFILE_RELEASE_LTO: 'off'
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
run: |
for attempt in 1 2 3; do
if cargo build --release --locked --target ${{ matrix.target }} -p codewhale-cli -p codewhale-tui; then
exit 0
fi
if [ "${attempt}" -lt 3 ]; then
echo "Build attempt ${attempt} failed; retrying in 30s..."
sleep 30
fi
done
echo "Build failed after 3 attempts" >&2
exit 1
- name: Stage artifact
id: stage
shell: bash
run: |
short_sha="${GITHUB_SHA::12}"
stage_one() {
local binary="$1"
local artifact="$2"
local dir="$3"
local bin_path="target/${{ matrix.target }}/release/${binary}"
if [ ! -f "${bin_path}" ]; then
echo "Binary not at ${bin_path}; searching target/ for ${binary}:"
find target -name "${binary}" -type f
exit 1
fi
mkdir -p "${dir}"
cp "${bin_path}" "${dir}/${artifact}"
cat > "${dir}/nightly-build-info.txt" <<INFO
repository=${GITHUB_REPOSITORY}
ref=${GITHUB_REF_NAME}
commit=${GITHUB_SHA}
artifact=${artifact}
profile=release-nightly-no-lto
INFO
}
stage_one "${{ matrix.cli_binary }}" "${{ matrix.cli_artifact }}" nightly-cli
stage_one "${{ matrix.tui_binary }}" "${{ matrix.tui_artifact }}" nightly-tui
echo "cli_name=${{ matrix.cli_artifact }}-${short_sha}" >> "${GITHUB_OUTPUT}"
echo "tui_name=${{ matrix.tui_artifact }}-${short_sha}" >> "${GITHUB_OUTPUT}"
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.stage.outputs.cli_name }}
path: nightly-cli/*
retention-days: 14
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.stage.outputs.tui_name }}
path: nightly-tui/*
retention-days: 14
+110
View File
@@ -0,0 +1,110 @@
name: Contribution gate - pull requests
on:
pull_request_target:
types: [opened, reopened]
permissions:
contents: read
issues: write
pull-requests: write
env:
# Keep new gates observable first. Switch to "enforce" only after maintainers
# have seeded active contributors and reviewed the dry-run signal.
CONTRIBUTION_GATE_MODE: dry-run
jobs:
gate:
runs-on: ubuntu-latest
steps:
- name: Gate unapproved external pull requests
uses: actions/github-script@v9
with:
script: |
const pr = context.payload.pull_request;
const owner = context.repo.owner;
const repo = context.repo.repo;
const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
const gateMode = (process.env.CONTRIBUTION_GATE_MODE || 'dry-run').trim().toLowerCase();
const enforceGate = gateMode === 'enforce';
if (!['dry-run', 'enforce'].includes(gateMode)) {
core.warning(`Unknown CONTRIBUTION_GATE_MODE "${gateMode}"; defaulting to dry-run.`);
}
if (privileged.has(pr.author_association)) return;
if (pr.user.login === 'github-actions[bot]') return;
function parseAllowlist(content) {
return new Set(
content
.split(/\r?\n/)
.map(line => line.replace(/#.*/, '').trim().toLowerCase())
.filter(Boolean)
);
}
async function readAllowlist() {
try {
const { data } = await github.rest.repos.getContent({
owner,
repo,
path: '.github/APPROVED_CONTRIBUTORS',
ref: context.payload.repository.default_branch,
});
if (Array.isArray(data) || data.type !== 'file') return new Set();
return parseAllowlist(
Buffer.from(data.content, data.encoding || 'base64').toString('utf8')
);
} catch (error) {
if (error.status === 404) return new Set();
throw error;
}
}
const allowlist = await readAllowlist();
const login = pr.user.login.toLowerCase();
if (
allowlist.has(`all:${login}`) ||
allowlist.has(`pr:${login}`)
) {
return;
}
const gateMessage = enforceGate
? 'This repository currently limits automated PR intake to contributors listed in `.github/APPROVED_CONTRIBUTORS`. This is a maintainer-safety control for code review and CI load, not a judgment on the contribution. A maintainer can grant recurring PR access with `/lgtm` after review; once the generated allowlist PR is merged, this pull request can be reopened or resubmitted.'
: 'This repository is observing a maintainer-managed PR intake gate in dry-run mode, so this pull request is staying open. This note helps maintainers prepare the allowlist before any enforcement is considered.';
const marker = '<!-- codewhale-pr-gate -->';
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: pr.number,
per_page: 100,
});
const alreadyNoted = comments.some(comment => (comment.body || '').includes(marker));
if (!alreadyNoted) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body: [
marker,
`Thanks @${pr.user.login} for taking the time to contribute.`,
'',
gateMessage,
'',
'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant recurring PR access by commenting `/lgtm` on a pull request.',
].join('\n'),
});
}
if (!enforceGate) return;
await github.rest.pulls.update({
owner,
repo,
pull_number: pr.number,
state: 'closed',
});
+652
View File
@@ -0,0 +1,652 @@
name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Package/release version to publish to npm, without the leading v'
required: true
type: string
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
jobs:
parity:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
components: clippy, rustfmt
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
# A cache bootstrap failure must degrade to an uncached build, never
# fail a release gate.
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
- name: Format check
run: cargo fmt --all -- --check
- name: Compile check
run: cargo check --workspace --all-targets --locked
- name: OHOS dependency graph
run: ./scripts/release/check-ohos-deps.sh
- name: Clippy
run: |
cargo clippy --workspace --all-targets --all-features --locked -- \
-D warnings \
-A clippy::uninlined_format_args \
-A clippy::too_many_arguments \
-A clippy::unnecessary_map_or \
-A clippy::collapsible_if \
-A clippy::assertions_on_constants
- name: Workspace tests
run: cargo test --workspace --all-features --locked
- name: Protocol schema parity
run: cargo test -p codewhale-protocol --test parity_protocol --locked
- name: State persistence parity
run: cargo test -p codewhale-state --test parity_state --locked
- name: Lockfile drift guard
run: git diff --exit-code -- Cargo.lock
resolve:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.release.outputs.tag }}
source_ref: ${{ steps.release.outputs.source_ref }}
sha: ${{ steps.release.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Resolve release source
id: release
shell: bash
run: |
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
tag="v${{ inputs.version }}"
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
sha="$(git rev-list -n 1 "${tag}")"
source_ref="${tag}"
else
# Tag doesn't exist yet — build from HEAD
sha="${GITHUB_SHA}"
source_ref="${GITHUB_SHA}"
echo "Tag ${tag} not found; building from ${source_ref} @ ${sha}"
fi
else
tag="${GITHUB_REF_NAME}"
sha="${GITHUB_SHA}"
source_ref="${GITHUB_REF_NAME}"
fi
if [ -z "${sha}" ]; then
echo "Unable to resolve release source for ${tag}" >&2
exit 1
fi
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "source_ref=${source_ref}" >> "$GITHUB_OUTPUT"
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
- name: Require release source on main
run: ./scripts/release/ensure-release-on-main.sh "${{ steps.release.outputs.sha }}"
build:
needs: [parity, resolve]
# `parity` is gated to tag-push events. On manual `workflow_dispatch`,
# parity is skipped, so let `build` proceed when resolve succeeded and
# parity either succeeded or was skipped — but never when parity actually
# failed or the run was cancelled. Operators using dispatch are expected
# to have already run the same gates locally / via ci.yml on `main`.
if: ${{ !cancelled() && needs.resolve.result == 'success' && (needs.parity.result == 'success' || needs.parity.result == 'skipped') }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
platform: linux-x64
cli_binary: codewhale
shim_binary: codew
tui_binary: codewhale-tui
cli_artifact: codewhale-linux-x64
shim_artifact: codew-linux-x64
tui_artifact: codewhale-tui-linux-x64
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
platform: linux-arm64
cli_binary: codewhale
shim_binary: codew
tui_binary: codewhale-tui
cli_artifact: codewhale-linux-arm64
shim_artifact: codew-linux-arm64
tui_artifact: codewhale-tui-linux-arm64
- os: ubuntu-latest
target: aarch64-linux-android
platform: android-arm64
cli_binary: codewhale
shim_binary: codew
tui_binary: codewhale-tui
cli_artifact: codewhale-android-arm64
shim_artifact: codew-android-arm64
tui_artifact: codewhale-tui-android-arm64
- os: macos-latest
target: x86_64-apple-darwin
platform: macos-x64
cli_binary: codewhale
shim_binary: codew
tui_binary: codewhale-tui
cli_artifact: codewhale-macos-x64
shim_artifact: codew-macos-x64
tui_artifact: codewhale-tui-macos-x64
- os: macos-latest
target: aarch64-apple-darwin
platform: macos-arm64
cli_binary: codewhale
shim_binary: codew
tui_binary: codewhale-tui
cli_artifact: codewhale-macos-arm64
shim_artifact: codew-macos-arm64
tui_artifact: codewhale-tui-macos-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
platform: windows-x64
cli_binary: codewhale.exe
shim_binary: codew.exe
tui_binary: codewhale-tui.exe
cli_artifact: codewhale-windows-x64.exe
shim_artifact: codew-windows-x64.exe
tui_artifact: codewhale-tui-windows-x64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.source_ref }}
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
targets: ${{ matrix.target }}
- name: Install Rust target
run: rustup target add --toolchain stable ${{ matrix.target }}
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
# A cache bootstrap failure must degrade to an uncached build, never
# fail a release gate.
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
- name: Install Linux system dependencies
if: runner.os == 'Linux' && matrix.target == 'aarch64-unknown-linux-gnu'
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- name: Build static Linux x64 binary (musl)
if: matrix.target == 'x86_64-unknown-linux-musl'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y musl-tools
rustup target add --toolchain stable x86_64-unknown-linux-musl
cargo build --release --locked --target x86_64-unknown-linux-musl -p codewhale-cli -p codewhale-tui
- name: Install AArch64 cross-compilation toolchain
if: matrix.target == 'aarch64-unknown-linux-gnu' && runner.os == 'Linux'
run: |
. /etc/os-release
sudo tee /etc/apt/sources.list.d/arm64.sources <<SRC
Types: deb
URIs: http://ports.ubuntu.com/ubuntu-ports/
Suites: ${UBUNTU_CODENAME} ${UBUNTU_CODENAME}-updates ${UBUNTU_CODENAME}-security
Components: main universe
Architectures: arm64
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
SRC
sudo dpkg --add-architecture arm64
sudo apt-get update -o Dir::Etc::sourcelist=/etc/apt/sources.list.d/arm64.sources -o Dir::Etc::sourceparts=- -o APT::Get::List-Cleanup=0
sudo apt-get install -y gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdbus-1-dev:arm64 pkg-config
- name: Configure Android NDK linker
if: matrix.target == 'aarch64-linux-android' && runner.os == 'Linux'
shell: bash
env:
ANDROID_NDK_VERSION: 27.2.12479018
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y libclang-dev
ndk="${ANDROID_NDK_ROOT:-${ANDROID_NDK_HOME:-}}"
linker=""
if [[ -n "${ndk}" ]]; then
linker="${ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang"
fi
if [[ -z "${linker}" || ! -x "${linker}" ]]; then
if ! command -v sdkmanager >/dev/null 2>&1; then
echo "sdkmanager is required to install Android NDK ${ANDROID_NDK_VERSION}" >&2
exit 1
fi
android_home="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -z "${android_home}" ]]; then
echo "ANDROID_HOME or ANDROID_SDK_ROOT is required to install Android NDK ${ANDROID_NDK_VERSION}" >&2
exit 1
fi
yes | sdkmanager --licenses >/dev/null || true
sdkmanager --install "ndk;${ANDROID_NDK_VERSION}"
ndk="${android_home}/ndk/${ANDROID_NDK_VERSION}"
linker="${ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang"
fi
ar="${ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
if [[ ! -x "${linker}" ]]; then
echo "Android linker not found: ${linker}" >&2
exit 1
fi
if [[ ! -x "${ar}" ]]; then
echo "Android archiver not found: ${ar}" >&2
exit 1
fi
echo "ANDROID_NDK_ROOT=${ndk}" >> "${GITHUB_ENV}"
echo "ANDROID_NDK_HOME=${ndk}" >> "${GITHUB_ENV}"
echo "CC_aarch64_linux_android=${linker}" >> "${GITHUB_ENV}"
echo "AR_aarch64_linux_android=${ar}" >> "${GITHUB_ENV}"
echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=${linker}" >> "${GITHUB_ENV}"
echo "BINDGEN_EXTRA_CLANG_ARGS_aarch64_linux_android=--target=aarch64-linux-android24 --sysroot=${ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot" >> "${GITHUB_ENV}"
- name: Build
if: matrix.target != 'x86_64-unknown-linux-musl'
shell: bash
env:
DEEPSEEK_BUILD_SHA: ${{ needs.resolve.outputs.sha }}
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
PKG_CONFIG_ALLOW_CROSS: 1
PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu: /usr/lib/aarch64-linux-gnu/pkgconfig
run: cargo build --release --locked --target ${{ matrix.target }} -p codewhale-cli -p codewhale-tui
- name: Stage binaries
shell: bash
run: |
stage_binary() {
local binary="$1"
local artifact="$2"
local bin_path="target/${{ matrix.target }}/release/${binary}"
if [ ! -f "${bin_path}" ]; then
echo "Binary not at ${bin_path}; searching target/ for ${binary}:"
find target -name "${binary}" -type f
exit 1
fi
cp "${bin_path}" "${artifact}"
}
stage_binary "${{ matrix.cli_binary }}" "${{ matrix.cli_artifact }}"
stage_binary "${{ matrix.shim_binary }}" "${{ matrix.shim_artifact }}"
stage_binary "${{ matrix.tui_binary }}" "${{ matrix.tui_artifact }}"
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.cli_artifact }}
path: ${{ matrix.cli_artifact }}
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.shim_artifact }}
path: ${{ matrix.shim_artifact }}
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.tui_artifact }}
path: ${{ matrix.tui_artifact }}
bundle:
needs: [build, resolve]
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.source_ref }}
- uses: actions/download-artifact@v8
with:
path: artifacts
pattern: '*'
- name: Create platform archives
shell: bash
run: |
set -euo pipefail
mkdir -p bundles/checksums
MANIFEST="bundles/checksums/codewhale-bundles-sha256.txt"
: > "$MANIFEST"
bundle() {
local platform="$1" # linux-x64, linux-arm64, android-arm64, macos-x64, macos-arm64, windows-x64
local cli_src="$2" # artifact name for codewhale binary
local shim_src="$3" # artifact name for codew shim
local tui_src="$4" # artifact name for codewhale-tui binary
local ext="$5" # tar.gz or zip
local variant="$6" # '' (standard) or 'portable' (Windows only, no install script)
shift 6
local dir="bundles/codewhale-${platform}${variant:+-}${variant}"
mkdir -p "$dir"
# Copy binaries, stripping platform suffixes
local cli_dst="codewhale"
local shim_dst="codew"
local tui_dst="codewhale-tui"
if [[ "$platform" == windows-* ]]; then
cli_dst="codewhale.exe"
shim_dst="codew.exe"
tui_dst="codewhale-tui.exe"
fi
cp "artifacts/${cli_src}/${cli_src}" "$dir/${cli_dst}"
cp "artifacts/${shim_src}/${shim_src}" "$dir/${shim_dst}"
cp "artifacts/${tui_src}/${tui_src}" "$dir/${tui_dst}"
# Add install script (standard variant only)
if [[ "$variant" != "portable" ]]; then
if [[ "$platform" == windows-* ]]; then
cp scripts/release/install.bat "$dir/"
# Convert line endings to CRLF for Windows
sed -i 's/$/\r/' "$dir/install.bat" 2>/dev/null || true
else
cp scripts/release/install.sh "$dir/"
chmod +x "$dir/install.sh"
fi
fi
if [[ "$ext" == "zip" ]]; then
(cd bundles && zip -r "codewhale-${platform}${variant:+-}${variant}.zip" "codewhale-${platform}${variant:+-}${variant}/")
else
tar -czf "bundles/codewhale-${platform}${variant:+-}${variant}.tar.gz" -C bundles "codewhale-${platform}${variant:+-}${variant}/"
fi
local archive="codewhale-${platform}${variant:+-}${variant}.${ext}"
sha256sum "bundles/${archive}" | awk '{printf "%s %s\n", $1, $2}' >> "$MANIFEST"
echo " Created bundles/${archive}"
}
# Platform: linux-x64
bundle linux-x64 \
codewhale-linux-x64 codew-linux-x64 codewhale-tui-linux-x64 tar.gz ""
# Platform: linux-arm64
bundle linux-arm64 \
codewhale-linux-arm64 codew-linux-arm64 codewhale-tui-linux-arm64 tar.gz ""
# Platform: android-arm64 (Termux)
bundle android-arm64 \
codewhale-android-arm64 codew-android-arm64 codewhale-tui-android-arm64 tar.gz ""
# Platform: macos-x64
bundle macos-x64 \
codewhale-macos-x64 codew-macos-x64 codewhale-tui-macos-x64 tar.gz ""
# Platform: macos-arm64
bundle macos-arm64 \
codewhale-macos-arm64 codew-macos-arm64 codewhale-tui-macos-arm64 tar.gz ""
# Platform: windows-x64 (standard + portable)
bundle windows-x64 \
codewhale-windows-x64.exe codew-windows-x64.exe codewhale-tui-windows-x64.exe zip ""
bundle windows-x64 \
codewhale-windows-x64.exe codew-windows-x64.exe codewhale-tui-windows-x64.exe zip "portable"
echo ""
echo "=== Archive checksums ==="
cat "$MANIFEST"
- name: Upload bundle artifacts
uses: actions/upload-artifact@v7
with:
name: codewhale-bundles
path: bundles/*
if-no-files-found: error
windows-installer:
needs: [build, resolve]
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.source_ref }}
- uses: actions/download-artifact@v8
with:
path: artifacts
pattern: '*windows-x64.exe'
- name: Install NSIS
shell: pwsh
run: choco install nsis -y --no-progress
- name: Build NSIS installer
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$version = "${{ needs.resolve.outputs.tag }}".TrimStart("v")
Copy-Item "artifacts\codewhale-windows-x64.exe\codewhale-windows-x64.exe" "scripts\installer\codewhale.exe"
Copy-Item "artifacts\codew-windows-x64.exe\codew-windows-x64.exe" "scripts\installer\codew.exe"
Copy-Item "artifacts\codewhale-tui-windows-x64.exe\codewhale-tui-windows-x64.exe" "scripts\installer\codewhale-tui.exe"
$makensis = "${env:ProgramFiles(x86)}\NSIS\makensis.exe"
if (!(Test-Path $makensis)) {
$makensis = "${env:ProgramFiles}\NSIS\makensis.exe"
}
if (!(Test-Path $makensis)) {
throw "makensis.exe not found after NSIS install"
}
Push-Location scripts\installer
& $makensis "/DVERSION=$version" "codewhale.nsi"
Pop-Location
if (!(Test-Path "scripts\installer\CodeWhaleSetup.exe")) {
throw "CodeWhaleSetup.exe was not produced"
}
- name: Upload installer artifact
uses: actions/upload-artifact@v7
with:
name: CodeWhaleSetup.exe
path: scripts/installer/CodeWhaleSetup.exe
if-no-files-found: error
docker:
needs: [build, resolve]
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout release source
uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.source_ref }}
path: source
- name: Checkout release infrastructure
uses: actions/checkout@v7
with:
path: infra
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Normalize image name
id: image
shell: bash
run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ steps.image.outputs.name }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=ref,event=tag
type=semver,pattern={{version}},value=${{ needs.resolve.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.resolve.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=semver,pattern=v{{major}},value=${{ needs.resolve.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=raw,value=${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=raw,value=v${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v7
env:
# The build record is useful in CI, but it is uploaded as a
# `.dockerbuild` artifact. The release job intentionally downloads
# all binary artifacts, so suppress the extra record artifact there.
DOCKER_BUILD_RECORD_UPLOAD: false
DOCKER_BUILD_SUMMARY: false
with:
context: source
file: infra/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
build-args: |
DEEPSEEK_BUILD_SHA=${{ needs.resolve.outputs.sha }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
release:
needs: [build, bundle, windows-installer, docker, resolve]
if: ${{ !cancelled() && needs.build.result == 'success' && needs.bundle.result == 'success' && needs.windows-installer.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
# Checked out into a subdirectory so it cannot clobber the downloaded
# artifacts; used for the release-body generator and the CHANGELOG.
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.tag }}
path: repo
- uses: actions/download-artifact@v8
with:
path: artifacts
pattern: '*'
- name: Generate Windows npm launcher asset
shell: bash
run: |
set -euo pipefail
mkdir -p artifacts/codewhale-windows-launcher
{
printf '@echo off\r\n'
printf 'where wt >nul 2>nul\r\n'
printf 'set NO_ANIMATIONS=1\r\n'
printf 'if "%%ERRORLEVEL%%"=="0" (\r\n'
printf ' wt --title CodeWhale cmd /k "%%~dp0codewhale-windows-x64.exe"\r\n'
printf ') else (\r\n'
printf ' "%%~dp0codewhale-windows-x64.exe"\r\n'
printf ')\r\n'
printf '\r\n'
} > artifacts/codewhale-windows-launcher/codewhale.bat
- name: List artifacts
run: find artifacts -type f
- name: Generate checksum manifest
shell: bash
run: |
mkdir -p artifacts/checksums
# Canonical manifest used by codewhale's `codewhale update` flow.
manifest="artifacts/checksums/codewhale-artifacts-sha256.txt"
: > "${manifest}"
while IFS= read -r -d '' file; do
hash="$(sha256sum "${file}" | awk '{print $1}')"
base="$(basename "${file}")"
printf '%s %s\n' "${hash}" "${base}" >> "${manifest}"
done < <(find artifacts -type f ! -path 'artifacts/checksums/*' -print0 | sort -z)
cat "${manifest}"
- name: Generate release body from CHANGELOG
shell: bash
run: |
./repo/scripts/release/generate-release-body.sh \
"${{ needs.resolve.outputs.tag }}" repo/CHANGELOG.md > release-body.md
- uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.resolve.outputs.tag }}
files: artifacts/*/*
prerelease: false
body_path: release-body.md
homebrew:
needs: [release, resolve]
if: ${{ !cancelled() && needs.release.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check Homebrew tap token
id: homebrew-token
env:
TOKEN: ${{ secrets.HOMEBREW_TAP_PAT || secrets.RELEASE_TAG_PAT }}
run: |
if [ -z "${TOKEN:-}" ]; then
echo "No Homebrew tap token configured; skipping tap update."
echo "available=false" >> "${GITHUB_OUTPUT}"
else
echo "available=true" >> "${GITHUB_OUTPUT}"
fi
# Checkout main (not the tag) so the release-infra script is always
# available, even for tags created before this workflow was added.
- uses: actions/checkout@v7
if: steps.homebrew-token.outputs.available == 'true'
with:
ref: main
- name: Download checksum manifest
if: steps.homebrew-token.outputs.available == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release download ${{ needs.resolve.outputs.tag }} \
--repo ${{ github.repository }} \
--pattern 'codewhale-artifacts-sha256.txt' \
--dir /tmp
- name: Update Homebrew tap
if: steps.homebrew-token.outputs.available == 'true'
env:
TAG: ${{ needs.resolve.outputs.tag }}
MANIFEST: /tmp/codewhale-artifacts-sha256.txt
TAP_REPO: Hmbown/homebrew-deepseek-tui
TOKEN: ${{ secrets.HOMEBREW_TAP_PAT || secrets.RELEASE_TAG_PAT }}
run: bash .github/scripts/update-homebrew-tap.sh
# npm publish is intentionally not automated. The npm account requires 2FA OTP
# on every publish, and a granular automation token that bypasses 2FA has not
# been provisioned. Release the npm wrapper manually from a developer machine
# after the GitHub Release has been created — see CLAUDE.md "Releases" for the
# exact commands.
+31
View File
@@ -0,0 +1,31 @@
name: Security audit
permissions: {}
on:
pull_request:
paths:
- '.github/workflows/security-audit.yml'
- '**/Cargo.toml'
- '**/Cargo.lock'
- '**/audit.toml'
push:
branches: [main, master]
schedule:
# Run weekly on Monday at 06:31 UTC
- cron: '31 6 * * 1'
env:
CARGO_TERM_COLOR: always
jobs:
self-audit:
name: cargo-audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Run cargo audit
run: cargo audit
+74
View File
@@ -0,0 +1,74 @@
name: Lock down obvious spam issues
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
lockdown:
runs-on: ubuntu-latest
steps:
- name: Auto-close spam patterns from new accounts
uses: actions/github-script@v9
with:
script: |
const issue = context.payload.issue;
const author = issue.user;
// Only consider brand-new accounts. If the user has been around
// long enough to file good-faith issues elsewhere, don't touch.
const created = new Date(author.created_at || 0);
const ageDays = (Date.now() - created.getTime()) / 86_400_000;
if (ageDays > 30) return;
const blob = `${issue.title || ''}\n${issue.body || ''}`;
const patterns = [
/\bcrypto\b/i,
/\bairdrop\b/i,
/\bnft\b/i,
/\bpresale\b/i,
/\busdt\b/i,
/\btg\s*@/i,
/\btelegram\s+@/i,
/\bt\.me\//i,
/\bwhatsapp\s+\+/i,
/\bseo\s+service/i,
/\bguest\s+post/i,
/\bbacklink/i,
/\bbuy\s+followers/i,
/\bjoin\s+our\s+(community|server|group)/i,
/\bpromot[ei]\s+your\b/i,
];
const hit = patterns.find(p => p.test(blob));
if (!hit) return;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: [
'This issue was auto-closed because the title or body matches',
'a spam pattern (paid promotion / unrelated link) and the author',
'account is less than 30 days old. If this is a real bug or',
'feature request, please reopen with a clearer description',
'(in English or 中文) of the project-relevant context.',
].join(' '),
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['spam'],
}).catch(() => {}); // ignore if label doesn't exist yet
+37
View File
@@ -0,0 +1,37 @@
name: Close stale issues
on:
schedule:
- cron: '17 5 * * *' # daily, off-peak
workflow_dispatch: {}
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
# Pinned from actions/stale@v10; update deliberately when refreshing the policy.
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899
with:
days-before-stale: 14
days-before-close: 7
stale-issue-message: >
This issue has been inactive for 14 days while waiting on
additional information. It will close automatically in 7 days
unless someone responds. If you still need help, drop a
comment with the requested details and a maintainer can
reopen.
close-issue-message: >
Closing for inactivity. Feel free to comment to reopen if
you can share the requested information.
stale-issue-label: 'stale'
only-labels: 'needs-info'
exempt-issue-labels: 'pinned,keep-open,release-blocker,security'
# Don't touch PRs — `actions/stale` defaults can be aggressive
# there. We only want it for `needs-info` issues.
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 60
+125
View File
@@ -0,0 +1,125 @@
name: Sync to CNB
# Mirror commits and release tags to cnb.cool/codewhale.net/codewhale
# so users behind GitHub-blocking networks can fetch the source and tagged
# releases from the Tencent-hosted mirror.
#
# Triggers:
# * push to main → mirrors that commit to CNB main
# * tag matching v* → mirrors that tag to CNB
# * release work branches → mirrors release-candidate refs for CNB preflight
# * fix/rebrand branches → mirrors first-party heavy Linux CI refs
# * Tencent release branches → mirrors Feishu/Lighthouse setup branches
# * workflow_dispatch → manual fallback if any of the above fails
#
# Why the rewrite (v0.8.31):
# The previous implementation used the opaque tencentcom/git-sync Docker
# action, which discovered every local ref via fetch-depth: 0 and tried
# to push them all — including dependabot/* branches that GitHub had
# locally. Those follow-on pushes ran without the configured credential
# helper in scope and failed with
# `fatal: could not read Username for 'https://cnb.cool'`
# No concurrency block meant the main-push and tag-push workflow runs
# that auto-tag.yml fires within seconds of each other raced. About
# half of recent runs failed for those two reasons combined.
on:
push:
branches:
- main
- 'work/v*'
- 'fix/*'
- 'rebrand/*'
- 'work/v*-feishu-*'
- 'work/v*-lighthouse*'
tags: ['v*']
workflow_dispatch: {}
# Serialize runs so the back-to-back main-push + tag-push from auto-tag.yml
# don't race each other rebasing onto CNB. cancel-in-progress: false so
# every commit actually arrives — we'd rather queue than drop.
concurrency:
group: cnb-sync
cancel-in-progress: false
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Push triggering ref to CNB
env:
CNB_TOKEN: ${{ secrets.CNB_GIT_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [ -z "${CNB_TOKEN:-}" ]; then
echo "::error::CNB_GIT_TOKEN secret is not set; cannot push to CNB." >&2
exit 1
fi
# URL-encode any '%' in the token so basic-auth doesn't break on
# special characters. CNB tokens are typically alphanumeric so
# this is belt-and-suspenders.
ENCODED_TOKEN="$(printf '%s' "${CNB_TOKEN}" | python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read(), safe=""))')"
REMOTE_URL="https://cnb:${ENCODED_TOKEN}@cnb.cool/codewhale.net/codewhale.git"
# Use a masked alias so the token never appears in log lines.
git remote add cnb "${REMOTE_URL}"
# Push with retry on transient failures (CNB rate-limits, DNS
# blips, etc.). Args after `kind` are forwarded to `git push`
# so callers can pass `--force-with-lease`, multiple refspecs,
# etc. without quoting them into one string.
push_with_retry() {
local kind="$1"
shift
local attempt
for attempt in 1 2 3; do
echo "Attempt ${attempt}: pushing ${kind} to CNB"
if git push cnb "$@" 2>&1; then
echo "Successfully pushed ${kind} to CNB"
return 0
fi
if [ "${attempt}" -lt 3 ]; then
sleep $((attempt * 5))
fi
done
echo "::error::Failed to push ${kind} to CNB after 3 attempts" >&2
return 1
}
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
TAG="${GITHUB_REF#refs/tags/}"
# Release tags may be repointed while rebuilding a failed
# publish attempt. CNB is a one-way mirror, so force the tag
# there to match GitHub instead of failing on "already exists".
push_with_retry "tag ${TAG}" "+refs/tags/${TAG}:refs/tags/${TAG}"
elif [[ "${GITHUB_REF}" == refs/heads/main ]]; then
# Plain --force. The CNB mirror is one-way by design —
# nothing else pushes to it, so there's no contributor work
# to protect against. `--force-with-lease` would be safer
# in a multi-writer scenario, but in our setup the lease
# check requires `refs/remotes/cnb/main` to exist in the
# runner's local clone, which it never does (we add `cnb`
# as a fresh remote in this step and don't fetch first).
# That made the lease check spuriously fail with
# `! [rejected] HEAD -> main (stale info)` even when CNB
# was actually behind GitHub.
push_with_retry "main" HEAD:refs/heads/main --force
else
# First-party fix/rebrand/release branches are first-class CNB
# sources for heavy Linux CI, release preflight, and
# Lighthouse/Feishu bootstrap.
# Mirror the triggering branch exactly so the CNB clone path stays
# useful before the branch has merged to main or become a release
# tag.
BRANCH="${GITHUB_REF#refs/heads/}"
push_with_retry "branch ${BRANCH}" "HEAD:refs/heads/${BRANCH}" --force
fi
+63
View File
@@ -0,0 +1,63 @@
name: Issue triage
on:
issues:
types: [opened, reopened]
permissions:
issues: write
contents: read
jobs:
label:
runs-on: ubuntu-latest
steps:
- name: Auto-label by title and body
uses: actions/github-script@v9
with:
script: |
const issue = context.payload.issue;
const title = (issue.title || '').toLowerCase();
const body = (issue.body || '').toLowerCase();
const text = `${title}\n${body}`;
const labels = new Set();
// Type
if (/\b(bug|crash|panic|broken|stack ?trace|regression|err(?:or)?|fail(?:ed|ure)?)\b/.test(text)) labels.add('bug');
if (/\b(feat(?:ure)?|request|enhancement|wishlist|proposal|please add|would be nice|support for)\b/.test(text)) labels.add('enhancement');
if (/\b(docs?|readme|documentation|typo|grammar|wording|spelling)\b/.test(text)) labels.add('documentation');
if (/\b(question|how (?:do|to)|why does|what does|is it possible)\b/.test(text)) labels.add('question');
// Locale — title contains CJK (Chinese, Japanese, Korean) characters
if (/[぀-ヿ㐀-鿿가-힯]/.test(issue.title || '')) labels.add('lang:zh');
// Areas (path-driven hints)
if (/crates\/tui|\btui\b|ratatui|composer|sidebar/.test(text)) labels.add('area:tui');
if (/crates\/core|engine|turn ?loop|agent ?loop/.test(text)) labels.add('area:core');
if (/crates\/mcp|\bmcp\b/.test(text)) labels.add('area:mcp');
if (/crates\/state|sqlite|sessions?|persistence/.test(text)) labels.add('area:state');
if (/crates\/execpolicy|approval|sandbox|seatbelt|landlock/.test(text)) labels.add('area:execpolicy');
if (/crates\/tools|tool[ _]call|tool[ _]registry/.test(text)) labels.add('area:tools');
if (/install|cargo install|npm install|scoop|homebrew|prebuilt|binary/.test(text)) labels.add('area:install');
if (/windows/.test(text)) labels.add('os:windows');
if (/macos|darwin|apple silicon/.test(text)) labels.add('os:macos');
if (/\blinux\b|ubuntu|debian|fedora|arch ?linux/.test(text)) labels.add('os:linux');
if (labels.size === 0) return;
// Only add labels that already exist on the repo to avoid creating noise.
const existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const existingNames = new Set(existing.map(l => l.name));
const toAdd = [...labels].filter(name => existingNames.has(name));
if (toAdd.length === 0) return;
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: toAdd,
});
@@ -0,0 +1,97 @@
name: v0.8.68 milestone hygiene
on:
issues:
types: [opened, labeled, milestoned]
workflow_dispatch:
permissions:
issues: write
contents: read
jobs:
sync-labels:
runs-on: ubuntu-latest
steps:
- name: Sync v0.8.68 label with milestone
uses: actions/github-script@v9
with:
script: |
const issue = context.payload.issue;
if (!issue) return;
const milestoneTitle = issue.milestone?.title || '';
const labels = (issue.labels || []).map(l => l.name);
const hasMilestone = milestoneTitle === 'v0.8.68';
const hasLabel = labels.includes('v0.8.68');
if (hasMilestone && !hasLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['v0.8.68'],
});
core.info(`Added v0.8.68 label to #${issue.number}`);
}
if (hasLabel && !hasMilestone && issue.state === 'open') {
const milestones = await github.paginate(
github.rest.issues.listMilestones,
{ owner: context.repo.owner, repo: context.repo.repo, state: 'open', per_page: 100 }
);
const target = milestones.find(m => m.title === 'v0.8.68');
if (target) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
milestone: target.number,
});
core.info(`Added #${issue.number} to v0.8.68 milestone`);
}
}
- name: Auto-label agent-task issues
uses: actions/github-script@v9
with:
script: |
const issue = context.payload.issue;
if (!issue) return;
// Only auto-label freshly opened issues. Running on `labeled`
// events re-adds labels (e.g. agent-ready) that a maintainer or
// triage agent deliberately removed, breaking the
// "agent-ready = startable now" contract on the v0.8.68 board.
if (context.payload.action !== 'opened') return;
const title = (issue.title || '').toLowerCase();
const body = (issue.body || '').toLowerCase();
const labels = new Set((issue.labels || []).map(l => l.name));
// Agent-ready tasks: title starts with v0.8.68 and has structured sections
if (title.startsWith('v0.8.68:') && body.includes('acceptance criteria')) {
labels.add('agent-ready');
}
// Area hints for v0.8.68 work
const text = `${title}\n${body}`;
if (/\bworkflow\b|whaleflow|workflow-runtime/.test(text)) labels.add('workflow-runtime');
if (/crates\/tui|\btui\b|ratatui/.test(text)) labels.add('tui');
if (/fleet|subagent|sub-agent/.test(text)) labels.add('subagents');
if (/catalog|openrouter|model.?picker|provider.?lake/.test(text)) labels.add('reliability');
const existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const existingNames = new Set(existing.map(l => l.name));
const toAdd = [...labels].filter(name => existingNames.has(name) && !(issue.labels || []).some(l => l.name === name));
if (toAdd.length === 0) return;
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: toAdd,
});
+82
View File
@@ -0,0 +1,82 @@
name: Web Frontend
on:
push:
branches: [master, main]
paths:
- 'web/**'
- '.github/workflows/web.yml'
pull_request:
branches: [master, main]
paths:
- 'web/**'
- '.github/workflows/web.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
lint:
name: Lint & Type Check
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Check facts drift
# facts.generated.ts is TRACKED (committed), so verify the committed
# copy matches the workspace BEFORE regenerating. Running prebuild first
# would self-heal the working tree and let a stale committed file pass
# (#3771). check:facts ignores the volatile generatedAt/latestRelease
# fields by design, so it is safe to run against the committed copy that
# exists at checkout.
run: npm run check:facts
- name: Generate derived facts
# Regenerate after the drift gate so tsc --noEmit (TS2307 without it) and
# the build use a current facts.generated.ts. When the gate passes this
# only refreshes the generatedAt timestamp.
run: npm run prebuild
- name: Check docs parity
# Fails CI when docs-map.ts references non-existent repo files or
# when website version / command snippets are stale.
run: npm run check:docs
- name: Run ESLint
run: npm run lint
- name: TypeScript type check
run: npx tsc --noEmit
deploy:
name: Deploy to Cloudflare
runs-on: ubuntu-latest
needs: lint
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'
defaults:
run:
working-directory: web
env:
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Check Cloudflare deploy environment
run: npm run check:deploy-env
- name: Build OpenNext bundle
run: npm run build && npx opennextjs-cloudflare build
- name: Deploy
run: npm run deploy