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
6 changes: 6 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# actionlint configuration, passed to the Actionlint job in ci.yml with -config-file. See https://github.com/rhysd/actionlint/blob/v1.7.12/docs/config.md.
self-hosted-runner:
# Every runner label a job here can name that GitHub does not host: ExaDev's own fleet (the runner-fallback-action's default preferred label, which determine-runner.yml resolves to while the fleet heartbeat is current) and the Blacksmith pool the resolver runs on and falls back to. actionlint reports any label missing from this list as unknown.
labels:
- exadev-runners
- blacksmith-2vcpu-ubuntu-2404
13 changes: 11 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Dependabot for the GitHub Actions this repository's workflows use. Every third-party action is pinned by full commit SHA with its release in a trailing comment, so a tag moved by someone outside ExaDev cannot change the code a job runs; Dependabot keeps those pins current by proposing the new SHA and comment together.
# Dependabot for the GitHub Actions this repository's workflows use and for its npm dependencies. Every third-party action is pinned by full commit SHA with its release in a trailing comment, so a tag moved by someone outside ExaDev cannot change the code a job runs; Dependabot keeps those pins current by proposing the new SHA and comment together. The npm entry reads the pnpm lockfile at the root and updates it with the manifests it covers.
#
# Every entry waits a week after a release before proposing it (cooldown), so a compromised or broken publish has time to be noticed and pulled before it reaches a pull request here. Security updates are not delayed by it.
# Every entry waits a week after a release before proposing it (cooldown), so a compromised or broken publish has time to be noticed and pulled before it reaches a pull request here. It is the same seven days as the minimum release age local package installs enforce, so Dependabot never proposes a version a local install would refuse. Security updates are not delayed by it.
version: 2
updates:
- package-ecosystem: github-actions
Expand All @@ -12,3 +12,12 @@ updates:
groups:
minor-and-patch:
update-types: ["minor", "patch"]
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
minor-and-patch:
update-types: ["minor", "patch"]
88 changes: 83 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
Expand All @@ -51,6 +52,8 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
Expand All @@ -73,6 +76,8 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
Expand All @@ -85,6 +90,68 @@ jobs:
- run: pnpm exec publint
- run: pnpm exec attw --pack

actionlint:
name: Actionlint
needs: determine-runner
runs-on: ${{ fromJson(needs.determine-runner.outputs.selected-runner) }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# actionlint and the shellcheck it runs on every `run:` script, each at an exact version and each archive checked against the SHA-256 committed here before it is unpacked. The digests match actionlint's release checksums file and the asset digests GitHub records for both releases. shellcheck is installed rather than taken from the runner, because the fleet image has none and actionlint silently skips script checks without it. The fleet is linux/arm64 and Blacksmith linux/x64, so the archive follows the runner.
- name: Install actionlint and shellcheck
env:
ACTIONLINT_VERSION: 1.7.12
SHELLCHECK_VERSION: 0.11.0
run: |
case "$RUNNER_ARCH" in
X64)
actionlint_arch=amd64 actionlint_sha256=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
shellcheck_arch=x86_64 shellcheck_sha256=b7af85e41cc99489dcc21d66c6d5f3685138f06d34651e6d34b42ec6d54fe6f6
;;
ARM64)
actionlint_arch=arm64 actionlint_sha256=325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6
shellcheck_arch=aarch64 shellcheck_sha256=68a8133197a50beb8803f8d42f9908d1af1c5540d4bb05fdfca8c1fa47decefc
;;
*)
echo "::error::No pinned actionlint or shellcheck build for runner architecture $RUNNER_ARCH"
exit 1
;;
esac
tools="$RUNNER_TEMP/workflow-linters"
mkdir -p "$tools"
cd "$tools"
actionlint_archive="actionlint_${ACTIONLINT_VERSION}_linux_${actionlint_arch}.tar.gz"
shellcheck_archive="shellcheck-v${SHELLCHECK_VERSION}.linux.${shellcheck_arch}.tar.gz"
curl -fsSLo "$actionlint_archive" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${actionlint_archive}"
curl -fsSLo "$shellcheck_archive" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/${shellcheck_archive}"
printf '%s %s\n' "$actionlint_sha256" "$actionlint_archive" "$shellcheck_sha256" "$shellcheck_archive" | sha256sum --check --strict
tar -xzf "$actionlint_archive" actionlint
tar -xzf "$shellcheck_archive" --strip-components=1 "shellcheck-v${SHELLCHECK_VERSION}/shellcheck"
echo "$tools" >> "$GITHUB_PATH"
# With no file arguments actionlint lints every workflow in the repository's own .github/workflows.
- name: Lint every workflow
run: actionlint -config-file .github/actionlint.yaml

zizmor:
name: Zizmor
needs: determine-runner
runs-on: ${{ fromJson(needs.determine-runner.outputs.selected-runner) }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# uvx rather than zizmorcore/zizmor-action: the action runs zizmor's container image and fails without a Docker daemon, which the fleet's runner pods do not have. setup-uv checks the uv download against its published checksum, and uv fetches a Python for zizmor's environment if the runner has none.
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: 0.12.19
enable-cache: false
# Offline, so the result never depends on whether a token happens to be in the environment: the online audits (impostor commits, known-vulnerable actions, ref confusion) only run with one. Every workflow, composite action and the Dependabot configuration in the repository is audited.
- name: Audit every workflow and action
run: uvx zizmor@1.30.1 --offline --config .github/zizmor.yml --no-progress .

mutation:
name: Mutation
# Manual only: run it from the Actions tab (workflow_dispatch) when a change warrants a full mutation pass. It is not a required check and does not gate a release.
Expand All @@ -94,6 +161,8 @@ jobs:
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
Expand Down Expand Up @@ -134,6 +203,8 @@ jobs:
- { node: '24', cosmiconfig: '10' }
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
Expand All @@ -145,12 +216,15 @@ jobs:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node }}
- name: Install the tarball into a scratch project
- name: Create a scratch project
run: |
mkdir "$RUNNER_TEMP/scratch"
cd "$RUNNER_TEMP/scratch"
npm init -y
npm install "$RUNNER_TEMP"/pack/*.tgz "cosmiconfig@${{ matrix.cosmiconfig }}"
# A consumer's install of the packed tarball beside the cosmiconfig major under test is what this job checks, so it has no lockfile by design. It is a step of its own so that zizmor's adhoc-packages audit can be ignored on this one line: zizmor reads an ignore comment only as a YAML comment, never inside a multi-line run script.
- name: Install the tarball into the scratch project
working-directory: ${{ runner.temp }}/scratch
run: npm install "$RUNNER_TEMP"/pack/*.tgz "cosmiconfig@${{ matrix.cosmiconfig }}" # zizmor: ignore[adhoc-packages]
- name: Load a config with extends through the installed package, as ESM and as CommonJS
working-directory: ${{ runner.temp }}/scratch
run: |
Expand All @@ -176,17 +250,20 @@ jobs:

required-checks:
name: Required Checks
needs: [determine-runner, commitlint, lint, typecheck, package]
needs: [determine-runner, commitlint, lint, typecheck, package, actionlint, zizmor]
# Always runs, even if a dependency failed, so it reports that failure itself rather than being skipped: a required check that is silently absent blocks a PR indefinitely instead of failing it visibly.
if: always()
runs-on: ${{ fromJson(needs.determine-runner.outputs.selected-runner) }}
timeout-minutes: 5
steps:
- name: Fail if any required job failed or was cancelled
# The needs context reaches the script through env rather than being expanded into it: job outputs are part of that JSON, and an expression expanded inside a single-quoted echo would let a quote in any of them end the string and run as shell.
env:
NEEDS_JSON: ${{ toJSON(needs) }}
run: |
if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
echo "One or more required jobs did not succeed:"
echo '${{ toJSON(needs) }}'
printf '%s\n' "$NEEDS_JSON"
exit 1
fi

Expand Down Expand Up @@ -219,7 +296,8 @@ jobs:
- run: pnpm install --frozen-lockfile
# Pinned to an explicit release rather than npm@latest, so a newly published (or compromised) latest never runs in the job that holds the OIDC token. Trusted publishing needs npm CLI >=11.5.1; raise the pin deliberately, choosing a release that is at least a week old.
- name: Upgrade npm for OIDC trusted publishing (needs npm CLI >=11.5.1)
run: npm install -g npm@11.20.0
# zizmor's adhoc-packages audit flags any install outside a lockfile; this one is a single exact version, chosen and raised deliberately as the comment above says, so it is ignored here rather than in the whole repository.
run: npm install -g npm@11.20.0 # zizmor: ignore[adhoc-packages]
# The last step before Release and the only place the key is provisioned: written to the runner's temp directory with mode 600 from the step's env (never a command line), together with GitHub's ed25519 host key, pinned so the connection is verified rather than trusted on first use (fingerprint SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU, as published at https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints). The step then proves the key has write access to the repository, because semantic-release falls back to an https URL with GITHUB_TOKEN embedded when its own SSH push check fails, and the ruleset would reject that push only after the release had started. The dry run targets a ref name that does not exist, so it can never be rejected as non-fast-forward when main has moved on since checkout (semantic-release itself skips that case cleanly, and the step must not turn it into a failure). GitHub does not evaluate rulesets on a dry run, so this does not prove the key is a bypass actor. BASH_ENV is blanked and ssh and git are called by absolute path so that a variable or PATH entry set by an earlier step does not redirect them.
- name: Provision the deploy key for the Release step
env:
Expand Down
11 changes: 11 additions & 0 deletions .github/zizmor.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# zizmor configuration, read by the Zizmor job in ci.yml (https://docs.zizmor.sh/configuration/).
rules:
unpinned-uses:
config:
# The same policy as workspace-conformance's workflow-action-pinning check with `organisations: [ExaDev]`: an action from outside the organisation must be pinned by full commit SHA, since a tag can be moved to different code by someone we do not control, while ExaDev's own actions may be referenced by tag so their owners can roll out a fix without every caller changing. zizmor's own default has required a SHA for every action, our own included, since 1.20.
policies:
"ExaDev/*": ref-pin
"*": hash-pin
self-repository:
# This audit asks for GitHub's `uses: $/...` self-repository syntax in place of `./...`. actionlint 1.7.12, the version the Actionlint job runs, rejects that syntax as a malformed action reference (rhysd/actionlint#711 is open for it), so adopting it would turn one linter's finding into the other's error. Every local reference here stays `./...` until actionlint accepts `$/`.
disable: true
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ CI also runs `pnpm exec publint` and `pnpm exec attw --pack` after the build. It

Both cosmiconfig majors are installed as dev dependencies under the aliases `cosmiconfig-9` and `cosmiconfig-10`, so `src/interop.integration.test.ts` exercises the loader and transform against each. `test/source` holds tests relocated from the project this package was extracted from, together with the domain they exercise, adapted onto the public API. They are the compatibility check for `extends` semantics.

CI also lints the workflows themselves with [actionlint](https://github.com/rhysd/actionlint) (and the shellcheck it runs on every `run:` script) and [zizmor](https://docs.zizmor.sh), each at a pinned version and configured in `.github/actionlint.yaml` and `.github/zizmor.yml`; both are part of the Required Checks status.

CI selects its runner with `ExaDev/runner-fallback-action` (self-hosted fleet first, Blacksmith as fallback).

## Releases
Expand Down