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
45 changes: 16 additions & 29 deletions .github/actions/verify-npm-hardening/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,22 @@

Composite GitHub Action that verifies supply chain security settings before `npm ci` runs. Catches missing or misconfigured settings before they reach production.

## What it checks

**Required files:**

| File | Why |
|---------------------|------------------------------------------------------------|
| `.npmrc` | Contains the hardening settings below |
| `package-lock.json` | Locks exact dependency versions for deterministic installs |

**Required `.npmrc` settings:**

| Setting | Purpose |
|-----------------------|-------------------------------------------------------------------------------|
| `ignore-scripts=true` | Blocks postinstall hooks (the main vector for npm supply chain attacks) |
| `engine-strict=true` | Refuses to install on unsupported Node/npm versions |
| `min-release-age=N` | Quarantines packages published less than N days ago (requires npm >= 11.10.0) |

**Non-registry dependency checks:**

| Check | Purpose |
|-------|---------|
| Git/URL deps in `package.json` | Detects dependencies that bypass `min-release-age` (git, GitHub, HTTP, file sources) |
| Non-registry URLs in `package-lock.json` | Detects tampered lockfiles resolving packages outside the npm registry |

**Recommended (not enforced):**

| Setting | Purpose |
|-------------------|---------------------------------------------------------------|
| `save-exact=true` | Pins new dependencies to exact versions instead of `^` ranges |
## Policy

This table is the canonical SciFY npm supply chain policy. The action enforces
every rule marked "fails". The `npm-harden` skill in the `scify-devops` Claude
Code plugin applies the same rules to a repository.

| # | Rule | Value | CI result | Why |
| --- | --- | --- | --- | --- |
| 1 | `.npmrc` is committed | file exists | fails | Holds the settings below |
| 2 | `package-lock.json` is committed | file exists | fails | `npm ci` needs it for a deterministic install |
| 3 | `ignore-scripts` | `true` | fails | Blocks install scripts, the main vector of npm supply chain attacks |
| 4 | `engine-strict` | `true` | fails | Refuses to install on Node or npm versions outside `engines` |
| 5 | `min-release-age` | a plain integer, at least 7 (days); the `min-age` input can raise the minimum | fails | Quarantines new releases. Needs npm 11.10.0 or later. `7d` is invalid and disables the protection |
| 6 | No git, URL, GitHub or `file:` dependencies in `package.json` | none allowed; `npm:` aliases are allowed | fails | These sources bypass `min-release-age` |
| 7 | Every `resolved` URL in `package-lock.json` points to the public npm registry | starts with `https://registry.npmjs.org/` (no other host, no `http://`) | fails | Detects a tampered lockfile |
| 8 | `save-exact` | `true` | not checked (recommended) | New dependencies get exact versions instead of `^` ranges |

## Setup

Expand Down
4 changes: 3 additions & 1 deletion .github/actions/verify-npm-hardening/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ runs:
const hits = [];
Object.entries(packages).forEach(([path, meta]) => {
const r = meta.resolved || '';
if (r && !/^https?:\/\/registry\.npmjs\.org\//.test(r) && !/^https?:\/\/registry\.npm\./.test(r) && path !== '')
// Only the public npm registry over HTTPS. A looser prefix such as
// registry.npm. would also accept registry.npm.attacker.example.
if (r && !/^https:\/\/registry\.npmjs\.org\//.test(r) && path !== '')
hits.push(path + ' -> ' + r);
});
hits.length ? hits.join('\n') : '';
Expand Down
67 changes: 65 additions & 2 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,16 @@ application, which inputs it accepts, and how to solve common problems.
Each workflow file also starts with a comment block that shows a full example call.
`self-check.yml` is the CI of this repository. Do not call it.

> **AI coding agents:** read [`AGENTS.md`](../../AGENTS.md) at the repository
> root first. To add these workflows to a repository, follow
> [Adopt in an existing repository](#adopt-in-an-existing-repository). SciFY
> developers with the `scify-devops` Claude Code plugin can run `/ci-setup`,
> which follows the same procedure.

## Contents

- [How a call works](#how-a-call-works)
- [Adopt in an existing repository](#adopt-in-an-existing-repository)
- [Laravel CI](#laravel-ci)
- [Node CI](#node-ci)
- [Security](#security)
Expand Down Expand Up @@ -53,8 +60,14 @@ jobs:

Rules that apply to every workflow here:

- **Pin to a tag.** Use `@v0.1`, not `@main`. A `v0.x` release can change
inputs, so read the release notes before you move to a new tag.
- **Workflows by tag, actions by SHA.** Call the reusable workflows with the
release tag, `@v0.1`, not `@main`. The tag moves to each fix release, so you
receive fixes without a change. Do not pin a workflow by SHA: you then stop
receiving fixes. Actions are different: the organisation requires every
action in a step (`steps: - uses:`) to be pinned by full commit SHA. This
includes actions from `scify/.github`, so
`scify/.github/.github/actions/<name>@v0.1` fails. A `v0.x` release can
change inputs, so read the release notes before you move to a new line.
- **Your file owns the triggers.** `on:`, `concurrency:` and `permissions:` go in
your file. The shared workflow only defines the jobs.
- **Inputs have defaults.** Set only the inputs that you want to change. A
Expand Down Expand Up @@ -99,6 +112,56 @@ by hand. Replace `$default-branch` with `main`.
require only `ci / CI`. When you add or remove jobs later, the rule stays the
same.

## Adopt in an existing repository

Use this procedure when a repository already has its own CI or security
workflows. It applies to people and to AI coding agents.

1. **Read what the repository runs today.** Read `.github/workflows/*.yml`,
`.github/actions/*`, the `scripts` in `composer.json` and `package.json`,
the test suites in `phpunit.xml`, `tmpDir` in `phpstan.neon`,
`cacheDirectory` in `rector.php`, and the files `.env.testing`, `.nvmrc`
and `.npmrc`.
2. **Choose the workflows.** A Laravel application uses `laravel-ci.yml` and
`security.yml`. An npm-only application uses `node-ci.yml` and
`security.yml`.
3. **Start from the template at the release tag.** For example:

```bash
gh api 'repos/scify/.github/contents/workflow-templates/laravel-ci.yml?ref=v0.1' \
-H 'Accept: application/vnd.github.raw' > .github/workflows/ci.yml
```

Replace `$default-branch` with the default branch, for example `main`.
4. **Map each existing command to an input.** Keep the repository's own
commands where they exist, for example `lint-command: composer check`. Leave
an input commented out when its default already does the same thing.
5. **Remove what the shared workflows replace.** Delete the old workflow
files and every local copy of `scan-dev-configs` or `verify-npm-hardening`.
6. **Run the repository's own checks on the new files.** Run its formatters and
linters, for example `npm run check` or `composer check`, and
`actionlint .github/workflows/*.yml`.
7. **Open a pull request.** Every `ci / ...` and `security / ...` check must
pass. Open the job logs and confirm that the expected steps ran.
8. **Update the required check.** In the branch ruleset or branch protection,
require `ci / CI` instead of the old check name. Do this together with the
merge.
9. **Check the first run on the default branch** after the merge.

### Pitfalls

| Pitfall | What happens | Do this |
| --- | --- | --- |
| An action from `scify/.github` pinned by tag | The run fails: the organisation requires actions pinned by SHA | Call `security.yml`, which loads its actions by itself. For a direct use, pin the action by SHA |
| A reusable workflow pinned by SHA | The repository stops receiving fixes | Use `@v0.1` |
| The repository's formatter does not run before the push | CI fails, for example Prettier on the new workflow file | Run the repository's own checks first (step 6) |
| A commented-out input under `# with:` | Prettier moves the comment, and the uncommented input no longer sits under `with:` | Keep the extra indentation after the `#`, as in the templates: `# input: value` |
| Two inputs run the same tool | Double run time, for example `composer check` that already includes `check:types` | Give each input a command that does not overlap with the others |
| Browser tests in `test-command` | They run in `Backend tests`, which has no browser, and fail | Exclude the browser suite, for example `--exclude-testsuite=Browser` |
| The required check still has the old name | Every new pull request waits for a check that never runs; Dependabot auto-merge stops | Require `ci / CI` (step 8) |
| A green job with nothing checked | For example "No package.json, skipping" | Read the job logs (step 7) |
| Old local copies of the actions stay | They drift and keep bugs that the shared copies fixed | Delete them (step 5) |

## Laravel CI

`laravel-ci.yml` runs static checks, backend tests and optional browser tests
Expand Down
28 changes: 24 additions & 4 deletions .github/workflows/self-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ jobs:
- name: Internal references use one release tag
run: |
tags=$(grep -rhoE --exclude-dir=.git --exclude-dir=fixtures \
'scify/\.github/\.github/(workflows|actions)/[A-Za-z0-9_-][A-Za-z0-9/_.-]*@v[0-9]+(\.[0-9]+)*|raw\.githubusercontent\.com/scify/\.github/v[0-9]+(\.[0-9]+)*' . \
'scify/\.github/\.github/(workflows|actions)/[A-Za-z0-9_-][A-Za-z0-9/_.-]*@v[0-9]+(\.[0-9]+)*|raw\.githubusercontent\.com/scify/\.github/v[0-9]+(\.[0-9]+)*|\?ref=v[0-9]+(\.[0-9]+)*' . \
| grep -oE 'v[0-9]+(\.[0-9]+)*$' | sort | uniq -c)
echo "$tags"
if [ "$(echo "$tags" | wc -l)" -ne 1 ]; then
Expand Down Expand Up @@ -137,8 +137,26 @@ jobs:
- name: verify-npm-hardening passes with the hardened .npmrc
uses: ./.github/actions/verify-npm-hardening

- name: Remove min-release-age from .npmrc
run: sed -i '/^min-release-age=/d' .npmrc
- name: Point a lockfile entry to a look-alike registry
run: |
cp package-lock.json package-lock.json.orig
node -e "
const fs = require('fs');
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
lock.packages['node_modules/left-pad'] = { version: '1.3.0',
resolved: 'https://registry.npm.evil.example/left-pad/-/left-pad-1.3.0.tgz' };
fs.writeFileSync('package-lock.json', JSON.stringify(lock, null, 2));
"

- name: verify-npm-hardening with a look-alike registry
id: npm-lookalike
continue-on-error: true
uses: ./.github/actions/verify-npm-hardening

- name: Restore the lockfile and remove min-release-age from .npmrc
run: |
mv package-lock.json.orig package-lock.json
sed -i '/^min-release-age=/d' .npmrc

- name: verify-npm-hardening without min-release-age
id: npm-weak
Expand All @@ -149,10 +167,12 @@ jobs:
env:
SCAN: ${{ steps.scan-blocked.outcome }}
NPM: ${{ steps.npm-weak.outcome }}
LOOKALIKE: ${{ steps.npm-lookalike.outcome }}
run: |
echo "scan-dev-configs without allowlist: $SCAN"
echo "verify-npm-hardening without min-release-age: $NPM"
if [ "$SCAN" != failure ] || [ "$NPM" != failure ]; then
echo "verify-npm-hardening with a look-alike registry: $LOOKALIKE"
if [ "$SCAN" != failure ] || [ "$NPM" != failure ] || [ "$LOOKALIKE" != failure ]; then
echo "::error::An action passed on a fixture that it must reject."
exit 1
fi
Expand Down
144 changes: 144 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# AGENTS.md

Instructions for AI coding agents (Claude Code, Codex, Copilot, Cursor and
others). People read [README.md](README.md) first.

This repository holds the shared GitHub material of the SciFY organisation:
reusable workflows, composite actions, workflow templates, Dependabot templates
and the organisation's default community files. It is public. Agent skills live
in a different repository, `scify/scify-agent-tools`.

| Your task | Read |
| --- | --- |
| Add CI or security checks to **another** SciFY repository | [Part 1](#part-1-use-the-shared-workflows-in-another-repository) |
| Change **this** repository | [Part 2](#part-2-change-this-repository) |

## Part 1: Use the shared workflows in another repository

**Current release tag: `v0.1`.** Call every workflow with `@v0.1`.

| Repository type | Workflows to call | Template |
| --- | --- | --- |
| Laravel (has `artisan` and `composer.json`) | `laravel-ci.yml` and `security.yml` | `workflow-templates/laravel-ci.yml`, `workflow-templates/security.yml` |
| npm only (has `package.json`, no `composer.json`) | `node-ci.yml` and `security.yml` | `workflow-templates/node-ci.yml`, `workflow-templates/security.yml` |
| Other (PHP library, WordPress, static site) | `security.yml` only | `workflow-templates/security.yml` |

Follow the procedure in
[Adopt in an existing repository](.github/workflows/README.md#adopt-in-an-existing-repository).
The [workflow guide](.github/workflows/README.md) lists every input with its
default and example values. If you have the `scify-devops` Claude Code plugin,
the `/ci-setup` skill runs the same procedure.

Read these files from the release tag, not from `main`:

```bash
gh api 'repos/scify/.github/contents/workflow-templates/laravel-ci.yml?ref=v0.1' \
-H 'Accept: application/vnd.github.raw'
gh api 'repos/scify/.github/contents/.github/workflows/README.md?ref=v0.1' \
-H 'Accept: application/vnd.github.raw'
```

Rules that cause failures when you break them:

1. **Workflows by tag, actions by SHA.** Call a reusable workflow with `@v0.1`.
The organisation requires every action in a step to be pinned by full commit
SHA, also actions from `scify/.github`. Do not call
`scify/.github/.github/actions/<name>@v0.1`: it fails. Use `security.yml`
instead, which runs both actions.
2. **Run the target repository's own checks before you push.** Its formatters
and linters also check the new workflow files. Then run
`actionlint .github/workflows/*.yml`.
3. **The required check becomes `ci / CI`.** Tell the user to update the branch
ruleset or branch protection. Do not change repository settings without the
user's approval.
4. **Read the job logs.** A green job can mean that a step was skipped, for
example "No package.json, skipping".
5. **Delete replaced files**: old CI or security workflows, and local copies of
`scan-dev-configs` or `verify-npm-hardening`.

The npm rules that `security.yml` enforces are in the
[npm policy](.github/actions/verify-npm-hardening/README.md#policy).

## Part 2: Change this repository

### Layout

| Path | Contents |
| --- | --- |
| `.github/workflows/laravel-ci.yml`, `node-ci.yml`, `security.yml` | Reusable workflows (`on: workflow_call`) |
| `.github/workflows/self-check.yml` | CI of this repository. Not reusable |
| `.github/workflows/README.md` | Workflow guide for callers |
| `.github/actions/scan-dev-configs/`, `verify-npm-hardening/` | Composite actions that `security.yml` runs |
| `workflow-templates/` | Starter workflows for the "New workflow" page, with `.properties.json` files |
| `templates/` | Dependabot templates that repositories copy |
| `scripts/` | Checker scripts that the self-check runs |
| `tests/fixtures/` | Sample projects for the smoke tests (see its README) |
| `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `ISSUE_TEMPLATE/`, `PULL_REQUEST_TEMPLATE.md`, `profile/` | Organisation defaults. GitHub applies them to every SciFY repository without its own copy |
| `tasks/` | Local notes. Git ignores this folder |

### Design rules

Keep these rules. Each one fixes a failure that happened.

1. **Scope.** Only content that is safe to publish. No secrets, hostnames or
deployment workflows: deployment lives in private repositories.
2. **Pin every third-party action by full commit SHA**, with the version in a
trailing comment: `uses: actions/checkout@<sha> # v7.0.1`.
3. **No tag-pinned own actions.** `security.yml` checks out this repository at
`job.workflow_sha` and runs the actions from `./.scify-github/...`. Do not
change this to `scify/.github/.github/actions/<name>@v0.1`.
4. **No composite actions for setup in the CI workflows.** `laravel-ci.yml` and
`node-ci.yml` share setup steps through YAML anchors (`&name` in the first
job, `*name` in the others). A setup step changes in the first job only.
5. **Tolerant defaults.** An empty `*-command` input means auto-detect: a tool
runs only when the repository has its config file.
6. **No `${{ }}` inside `run:`.** Pass inputs through `env:` and run custom
commands with `bash -c "$VAR"`.
7. **`shell: bash` runs with `-e -o pipefail`.** `x=$(grep ...)` stops the step
when grep finds nothing. Add `|| true` where no match is a valid result.
8. **Every input description ends with example values.** The guide documents
every input and default. `scripts/check-workflow-docs.py` fails otherwise.
9. **Templates list every input**, commented out, with the extra indentation
after the `#` (`# input: value`), so Prettier in a caller repository does
not move them.
10. **Every internal `@vX` reference uses the same tag.** The self-check fails
otherwise.
11. **Every check needs a case that must fail.** The `security-actions` job runs
each action on a broken fixture and expects a failure.
12. **Write documentation in ASD-STE100 Simplified Technical English**: active
voice, short sentences, one instruction per sentence.

### Check your change locally

The `Self-check` workflow runs these on every pull request. Run them before you
push:

```bash
actionlint .github/workflows/*.yml # needs shellcheck on PATH for the run: scripts
python3 scripts/shellcheck-actions.py
python3 scripts/check-workflow-docs.py
```

The smoke tests (the fixtures in `tests/fixtures/`) run only on GitHub. Open a
pull request and read the logs of the jobs that your change affects.

### Release

Releases follow [Versioning](README.md#versioning) in the README:

1. Merge to `main` and wait for a green `Self-check`.
2. Tag an immutable release, for example `v0.1.4`, and move the short tag
`v0.1` to the same commit. The ruleset `protect-release-tags` allows this
only for organisation owners.
3. Publish GitHub release notes for the new tag.

Changes to documentation, templates or fixtures only need no release. GitHub
reads templates from `main`.

### Commits and pull requests

- One logical change per commit. The subject is an imperative sentence of at
most 72 characters.
- Do not add AI attribution lines (`Co-Authored-By`, "Generated with").
- Pull request descriptions explain what changed and why. Do not add a "Test
plan" section.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ This repository is public. GitHub requires that for the default files, the profi
README and the workflow templates to take effect. Do not commit secrets, hostnames
or internal URLs here.

**Using an AI coding agent?** Point it to [AGENTS.md](AGENTS.md). It tells the
agent how to add these workflows to a repository and how to change this one.

## Scope

This repository holds only content that is safe to publish:
Expand Down
4 changes: 4 additions & 0 deletions profile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ accessibility, education, and inclusion.
Every public repository accepts issues and pull requests. Start with the
organisation [contributing guide](https://github.com/scify/.github/blob/main/CONTRIBUTING.md).

SciFY repositories share their CI and security checks through
[scify/.github](https://github.com/scify/.github). AI coding agents start with
its [AGENTS.md](https://github.com/scify/.github/blob/main/AGENTS.md).

## Contact

Website: <https://www.scify.org>
Loading