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
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:
@@ -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}`,
|
||||
});
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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:*)"
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user