diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml new file mode 100644 index 0000000..c48d3d2 --- /dev/null +++ b/.github/actions/prepare/action.yml @@ -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 diff --git a/.github/workflows/check-member.yml b/.github/workflows/check-member.yml new file mode 100644 index 0000000..e2e5bfd --- /dev/null +++ b/.github/workflows/check-member.yml @@ -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 " / ", 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'; + } diff --git a/.github/workflows/check-ticket.yml b/.github/workflows/check-ticket.yml index 4077e65..279700d 100644 --- a/.github/workflows/check-ticket.yml +++ b/.github/workflows/check-ticket.yml @@ -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 @@ -37,6 +37,9 @@ concurrency: jobs: check-ticket: + # API. A reusable call reports as " / ", 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: diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 0000000..a7d647c --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -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 }}}} diff --git a/README.md b/README.md index 58fd580..2597482 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ on: 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 ``` @@ -51,30 +51,158 @@ uppercase key, so `nas-12345` fails with a message saying so. Callers own their `on:` trigger — a reusable workflow has no say in what triggers its caller. -This one is **policy, not just plumbing.** Only `truenas/webui` requires tickets -today; `iXsystems/truenas-ui-components` deliberately treats the ticket prefix -as optional (see its `pr-title.yml`), and `truenas-connect/ui` has no PR-title -check at all. Adopt it only where the team has agreed to require tickets. +This one is **policy, not just plumbing** — it makes a ticket mandatory. All +three consumers have since agreed to that, but a fourth repo should adopt it +only once its team has. Note that `iXsystems/truenas-ui-components` requires a +ticket *and* a Conventional Commits title; the latter stays in its own local +`pr-title.yml`, since it is the only repo running semantic-release. -## Adoption status +### `check-member.yml` + +Reports whether the PR author has write access to the calling repo, as an +`is_member` output. Two distinct uses, which is why it is its own file: + +```yaml +jobs: + check-member: + if: github.event_name == 'pull_request' + permissions: + contents: read + uses: iXsystems/ux-github-workflows/.github/workflows/check-member.yml@master + + test-ux-team: + needs: [check-member] + if: needs.check-member.outputs.is_member == 'true' + runs-on: self-hosted + # ... +``` -| Repo | `check-ticket.yml` | +| Output | Notes | |---|---| -| `truenas/webui` | migrating (first adopter) | -| `iXsystems/truenas-ui-components` | n/a — tickets optional there | -| `truenas-connect/ui` | n/a — no PR-title check | +| `is_member` | `'true'` / `'false'` — a string, not a boolean. Compare with `== 'true'` | -## Releasing +`claude-review.yml` calls it to gate spend; `main.yml` in `truenas/webui` and +`truenas-connect/ui` calls it to route tests to the self-hosted runner. Those +were three separate copies of the same script before this existed — two +workflow files plus one inlined directly in `truenas-connect/ui`'s `main.yaml`. + +Only meaningful on `pull_request` events: it reads +`context.payload.pull_request`, and reports `'false'` on any event that has no +PR payload rather than failing. Guarding the job with +`if: github.event_name == 'pull_request'` is still worth doing to skip a +pointless runner — but then the downstream job needs `always()` (or +`!cancelled()`) plus an explicit `!= 'true'`, so the skip does not cascade into +it. See `truenas/webui`'s `main.yml` for the worked example. + +If the permission lookup fails it falls back to `author_association`, which is +deliberately permissive. It decides where tests run and whether a review +happens; it must not be load-bearing for anything that gates a merge. + +### `claude-review.yml` -Callers pin `@v1`, so a change reaches them only when the tag moves: +Automatic Claude PR review, gated on the PR author having write access. -```bash -git tag -f v1 && git push -f origin v1 +```yaml +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 }} ``` -Land the change on `master`, verify it against the first adopter's next PR, then -move the tag. For a breaking input change, cut `v2` and migrate callers one at a -time instead. +| Input | Default | Notes | +|---|---|---| +| `model` | `claude-opus-5` | | +| `prompt-file` | `.claude/review-prompt.md` | Repo-relative; the file stays in the consumer repo | +| `require-write-access` | `true` | Keep on for public repos | +| `skip-label` | `skip-claude` | | +| `timeout-minutes` | `20` | | +| `fetch-depth` | `10` | | +| `additional-permissions` | `''` | Extra reviewer capabilities, e.g. `gh pr list, gh pr view, gh api --method GET`. Opt-in per repo | + +The API key is an explicit named secret rather than `secrets: inherit`, because +consumers name it differently (`CLAUDE_API_KEY` vs `CLAUDE_TOKEN`). + +The `anthropics/claude-code-action` version is **hardcoded**, not an input: +`uses:` does not evaluate expressions, and making it configurable is what let +the consumers drift to v1.0.182 / v1.0.154 / v1.0.134 in the first place. + +The member gate is `check-member.yml`, called by full `iXsystems/…@master` path. +It has to be the full path: inside a reusable workflow a relative `uses:` +resolves against the *caller's* repo, so `./.github/workflows/check-member.yml` +would be looked for in webui. This nests two levels deep (caller → +`claude-review` → `check-member`), well inside GitHub's limit of four. + +## Actions + +### `.github/actions/prepare` + +A **composite action**, not a reusable workflow: it runs as a step inside an +existing job, so the caller keeps its own `runs-on`, `permissions` and checkout. +Reusable workflows cannot do that — they bring their own job. + +```yaml +steps: + - uses: actions/checkout@v4 # required first; this installs into the workspace + - uses: iXsystems/ux-github-workflows/.github/actions/prepare@master + with: + cache-jest: 'true' # optional +``` + +| Input | Default | Notes | +|---|---|---| +| `node-version` | `24.13.1` | Pinned, not floating | +| `cache-jest` | `'false'` | Caches `.jest/cache`; only useful where Jest runs | +| `yarn-cache` | `'false'` | Caches Yarn's global cache folder | + +Inputs are strings — every composite-action input is. Compare with `== 'true'`. + +**Step order is load-bearing.** `actions/setup-node` runs *before* +`corepack enable`, because Corepack writes its shims into the active Node +installation's bin directory: enable it first and then let setup-node swap in a +different Node, and `yarn` goes missing. That is also why setup-node's own +`cache: 'yarn'` is not used — it shells out to `yarn` before Corepack has run, +and would either fail or silently cache Yarn 1's directory for a Yarn 4 repo. +The `yarn-cache` input resolves the folder with `yarn config get cacheFolder` +after Corepack instead. + +This replaced identical local copies in `truenas/webui` and `truenas-connect/ui` +and six inline repetitions in `iXsystems/truenas-ui-components`'s `ci-cd.yml`, +which had drifted to a floating `'24'` against the others' pinned `24.13.1`. + +## Adoption status + +| Repo | `check-ticket` | `check-member` | `claude-review` | `prepare` | +|---|---|---|---|---| +| `truenas/webui` | adopted | migrating (`main.yml`) | migrating | migrating | +| `iXsystems/truenas-ui-components` | adopted | n/a — no self-hosted runner | migrating | migrating | +| `truenas-connect/ui` | adopted | migrating (`main.yaml`) | migrating | migrating | + +`claude-review.yml` pulls in `check-member.yml` on its own, so a repo using only +the review does not call it directly — the `check-member.yml` column tracks +`main.yml`-style direct callers. + +## Releasing + +Callers reference `@master`, so **anything landing on `master` is live in every +consumer immediately** — there is no per-repo review gate between a change here +and three repos' CI running it. + +That puts the whole burden on the PR into this repo: + +- Treat a change to a job `name:` as breaking. Consumers match + `" / "` in branch protection, so a rename silently + stops a required check reporting, with no PR in their repo to explain it. +- Same for removing or renaming an input, or tightening a default. +- Verify against one consumer's next real PR before assuming it is fine + everywhere; the consumers differ in trigger, secret names and permissions. -Tags are the release surface here, not branches — a caller pinned to `@master` -would pick up unreviewed changes on every push to every consumer at once. +If that becomes too sharp an edge, the alternative is tagging: cut `v1`, move +callers to `@v1`, and release with `git tag -f v1 && git push -f origin v1`. +That was the original intent, but with only three consumers and one team it was +judged more ceremony than it buys.