Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/actions/prepare/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: 'Prepare: Node and Yarn'
description: 'Sets up Node, enables Corepack for Yarn 4, restores caches and installs dependencies.'

# Composite action, not a reusable workflow: this runs as a *step* inside an
# existing job, so the caller keeps its own runs-on, permissions and checkout.
#
# The caller must `actions/checkout` first — this installs into whatever is
# already in the workspace.
#
# Usage:
# steps:
# - uses: actions/checkout@v4
# - uses: iXsystems/ux-github-workflows/.github/actions/prepare@master
# with:
# cache-jest: 'true' # optional
#
# Inputs are strings, as all composite-action inputs are — compare with
# `== 'true'`, not as booleans.

inputs:
node-version:
description: >-
Exact Node version. Pinned rather than floating on purpose: the library
and the apps that consume it should build on the same Node.
required: false
default: '24.13.1'
cache-jest:
description: "Cache .jest/cache, keyed on yarn.lock. Only useful in repos that run Jest."
required: false
default: 'false'
yarn-cache:
description: "Cache Yarn's global cache folder, keyed on yarn.lock."
required: false
default: 'false'

runs:
using: 'composite'
steps:
# Order matters: setup-node must come before `corepack enable`. Corepack
# writes its shims into the active Node installation's bin directory, so
# enabling it first and then letting setup-node swap in a different Node
# leaves `yarn` missing. This is also why setup-node's own `cache: 'yarn'`
# is not used — it shells out to `yarn` before Corepack has run.
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}

- name: Enable Corepack for Yarn 4
shell: bash
run: corepack enable

- name: Resolve Yarn cache folder
if: inputs.yarn-cache == 'true'
id: yarn-cache-dir
shell: bash
run: |
dir="$(yarn config get cacheFolder)"
# An empty value would reach actions/cache as `path: ''` and fail there
# with a Path Validation Error that says nothing about Yarn. Fail here.
if [ -z "$dir" ]; then
echo "::error::Could not resolve the Yarn cache folder. Is this a Yarn 4 project with a packageManager field?"
exit 1
fi
echo "dir=$dir" >> "$GITHUB_OUTPUT"

- name: Cache Yarn packages
if: inputs.yarn-cache == 'true'
uses: actions/cache@v4
with:
path: ${{ steps.yarn-cache-dir.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-

- name: Cache Jest cache
if: inputs.cache-jest == 'true'
uses: actions/cache@v4
with:
path: .jest/cache
key: ${{ runner.os }}-jest-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-jest-

- name: Install packages
if: inputs.install == 'true'
shell: bash
run: yarn install --immutable
96 changes: 96 additions & 0 deletions .github/workflows/check-member.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: Check Member Access (shared)

# Reports whether the PR author has write access to the calling repo, as an
# `is_member` output. Consumers use it two ways:
#
# - to gate spend (claude-review.yml calls this before reviewing), and
# - to route work (main.yml sends team PRs to the self-hosted test runner and
# everyone else to ubuntu-latest).
#
# Usage:
# jobs:
# check-member:
# if: github.event_name == 'pull_request'
# permissions:
# contents: read
# uses: iXsystems/ux-github-workflows/.github/workflows/check-member.yml@master
#
# something:
# needs: [check-member]
# if: needs.check-member.outputs.is_member == 'true'
#
# Only meaningful on `pull_request` events — it reads
# `context.payload.pull_request`. Callers that also run on push must guard the
# job with `if: github.event_name == 'pull_request'`, and then use `always()`
# plus an explicit `!= 'true'` on the downstream job so the skip does not
# cascade. See truenas/webui's main.yml for the worked example.

on:
workflow_call:
outputs:
is_member:
description: "'true' if the PR author has write or admin access to the calling repo."
value: ${{ jobs.check.outputs.is_member }}

permissions:
contents: read

jobs:
check:
# API. A reusable call reports as "<caller job id> / <this name>", so consumers
# match this string in branch protection. Renaming it stops their required check
# reporting, silently, with no PR in their repo to explain it.
name: Check member access
runs-on: ubuntu-latest
outputs:
is_member: ${{ steps.check.outputs.result }}
steps:
- name: Check membership
id: check
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
result-encoding: string
script: |
// Guard first. Both the happy path and the fallback below read
// `pull_request`, so on any other event the fallback used to throw
// a second TypeError *inside* the catch — uncaught, failing the job
// rather than answering 'false'. Returning here keeps the job green
// and the `is_member` output defined for downstream `needs`.
const pullRequest = context.payload.pull_request;
if (!pullRequest) {
core.info(`No pull_request payload on a '${context.eventName}' event — reporting not-a-member.`);
return 'false';
}

try {
const username = pullRequest.user.login;
console.log(`Checking repository access for user: ${username}`);

const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: username
});

console.log(`User ${username} has permission: ${permissionLevel.permission}`);

const hasWriteAccess = ['write', 'admin'].includes(permissionLevel.permission);
console.log(`Has write access: ${hasWriteAccess}`);

return hasWriteAccess ? 'true' : 'false';
} catch (error) {
console.log(`Error checking permissions: ${error.message}`);

// Fall back to the PR author association when the permission
// lookup fails (e.g. the token cannot read org membership).
// Deliberately permissive: this decides where tests run and
// whether a review happens, not whether anything merges.
const association = pullRequest.author_association;
console.log(`PR author association: ${association}`);

const isTeamMember = ['MEMBER', 'OWNER', 'COLLABORATOR'].includes(association);
console.log(`Is team member based on association: ${isTeamMember}`);

return isTeamMember ? 'true' : 'false';
}
5 changes: 4 additions & 1 deletion .github/workflows/check-ticket.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ name: Check Ticket (shared)
#
# jobs:
# check-ticket:
# uses: iXsystems/ux-github-workflows/.github/workflows/check-ticket.yml@v1
# uses: iXsystems/ux-github-workflows/.github/workflows/check-ticket.yml@master
# with:
# ticket-prefixes: TNC # optional; defaults to NAS

Expand All @@ -37,6 +37,9 @@ concurrency:

jobs:
check-ticket:
# API. A reusable call reports as "<caller job id> / <this name>", so consumers
# match this string in branch protection. Renaming it stops their required check
# reporting, silently, with no PR in their repo to explain it.
name: Check PR references a ticket
runs-on: ubuntu-latest
steps:
Expand Down
129 changes: 129 additions & 0 deletions .github/workflows/claude-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: Claude Review (shared)

# Shared automatic-PR-review workflow for the TrueNAS Angular repos
# (truenas/webui, iXsystems/truenas-ui-components, truenas-connect/ui).
#
# Callers own their `on:` trigger — branch filters and paths-ignore differ per
# repo and cannot be passed as inputs, since `workflow_call` has no say in what
# triggers the caller. Everything else lives here.
#
# Usage:
# jobs:
# claude-review:
# uses: iXsystems/ux-github-workflows/.github/workflows/claude-review.yml@master
# permissions:
# contents: read
# issues: write
# pull-requests: write
# id-token: write
# secrets:
# anthropic-api-key: ${{ secrets.CLAUDE_API_KEY }}

on:
workflow_call:
inputs:
model:
description: 'Model passed via claude_args.'
type: string
default: 'claude-opus-5'
prompt-file:
description: 'Repo-relative path to the review guidelines appended to the prompt.'
type: string
default: '.claude/review-prompt.md'
require-write-access:
description: 'Gate the review on the PR author having write/admin access. Keep true on public repos — it is what stops drive-by PRs from spending tokens.'
type: boolean
default: true
skip-label:
description: 'PR label that suppresses the review.'
type: string
default: 'skip-claude'
timeout-minutes:
description: 'Hard cap on the review job.'
type: number
default: 20
fetch-depth:
description: 'Checkout depth. Needs to cover the PR range for the diff.'
type: number
default: 10
additional-permissions:
description: >-
Extra capabilities granted to the review, as understood by
claude-code-action, e.g. "gh pr list, gh pr view, gh api --method GET".
Empty by default: this widens what the reviewer can do, so a repo opts
in rather than inheriting it from the other consumers.
type: string
default: ''
secrets:
anthropic-api-key:
description: 'Anthropic API key. Mapped by the caller, since the secret name differs per repo.'
required: true

# One review per PR. Rapid pushes previously started overlapping reviews that
# raced to overwrite the same sticky comment, and paid for every superseded run.
# Groups are scoped to the calling repository, so the PR number alone is enough.
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
# Gate: does the PR author have write access to the calling repo?
#
# Referenced by its full `iXsystems/...@ref` path, not a relative one: inside a
# reusable workflow a relative `uses:` resolves against the *caller's* repo, so
# `./.github/workflows/check-member.yml` would look for the file in webui.
#
# It is a separate file rather than inlined here because main.yml in webui and
# truenas-connect/ui needs the same answer to pick a test runner — inlining
# would put a second copy of the script in the repo that exists to remove them.
check-member:
if: inputs.require-write-access
permissions:
contents: read
uses: iXsystems/ux-github-workflows/.github/workflows/check-member.yml@master

review:
name: Automatic PR review
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout-minutes }}
needs: [check-member]
# `!cancelled()` rather than a bare `always()`: the job still has to run when
# check-member is *skipped* (gate off) instead of inheriting that skip, but
# `always()` would also push a review through after the run was cancelled —
# spending tokens on work someone explicitly stopped. A failed check-member
# leaves is_member empty, so the gate stays fail-closed either way.
if: |
!cancelled() &&
(inputs.require-write-access == false || needs.check-member.outputs.is_member == 'true') &&
!contains(github.event.pull_request.labels.*.name, inputs.skip-label)
permissions:
contents: read
issues: write
pull-requests: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: ${{ inputs.fetch-depth }}

# The action version is deliberately NOT an input: `uses:` does not
# evaluate expressions, and making it configurable would recreate the
# drift this workflow exists to remove (the three repos were on v1.0.182,
# v1.0.154 and v1.0.134). Bump it here to upgrade every caller at once.
- name: Automatic PR Review
uses: anthropics/claude-code-action@v1.0.182
with:
anthropic_api_key: ${{ secrets.anthropic-api-key }}
claude_args: "--model ${{ inputs.model }}"
additional_permissions: ${{ inputs.additional-permissions }}
track_progress: true
use_sticky_comment: true
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}

Please review this pull request using the guidelines below.
It should be already checked out in the current directory.

{{file:${{ inputs.prompt-file }}}}
Loading