Skip to content
Merged
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
94 changes: 94 additions & 0 deletions .github/actions/prepare/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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-

# Unconditional, and deliberately so. This step once had an `if:` guard on an
# optional install toggle; the toggle was removed and the guard was left
# behind. A composite action resolves an undeclared input to the empty
# string rather than failing, so the condition quietly became false and every
# caller got a green "Install" step that installed nothing — the jobs that
# then tried to use node_modules were the ones that failed.
# Never add an `if:` here without declaring the input it reads.
- name: Install packages
shell: bash
run: yarn install --immutable
94 changes: 94 additions & 0 deletions .github/workflows/check-member.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Check Member Access (shared)

# Reports whether the PR author has write access to the calling repo, as an
# `is_member` output. Used to route work: main.yml in truenas/webui and
# truenas-connect/ui 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
69 changes: 69 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: CI

# This repository had no checks of its own. Both of the workflows it publishes
# are `on: workflow_call`, and a reusable workflow never triggers on its own
# pull requests — so nothing validated this repo before a merge.
#
# That matters more here than in an ordinary repo. Consumers reference @master,
# so anything merged is live in three repos immediately, and a mistake surfaces
# as *their* CI breaking, with no pull request of their own to explain why.

on:
pull_request:
push:
branches:
- master

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
actionlint:
name: Lint workflows
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

# Covers the workflow files: expression syntax, unknown keys, bad
# `runs-on`, shellcheck over `run:` blocks. It does NOT check the
# `action.yml` of a composite action — that gap is why the job below
# exists as well.
- name: Run actionlint
uses: docker://rhysd/actionlint:1.7.7
with:
args: -color

input-refs:
name: Check input references
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '24.13.1'

- name: Install js-yaml
run: npm install --no-save --no-package-lock js-yaml

- name: Check every inputs.* reference is declared
run: node scripts/check-input-refs.js

# Smoke test: this repo calls its own reusable workflow, so a change to
# check-member.yml is executed before it can be merged rather than after.
#
# Deliberately runs on push as well as pull_request. On a push there is no
# pull_request payload, which exercises the guard that reports 'false' instead
# of throwing — the path that used to fail the job outright.
self-test:
name: Self-test
permissions:
contents: read
uses: ./.github/workflows/check-member.yml
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# CI installs js-yaml into the workspace to run scripts/check-input-refs.js
node_modules/
Loading
Loading