Skip to content
Draft
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
166 changes: 150 additions & 16 deletions .github/workflows/crap-crates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,14 @@ name: CRAP gate (crates)
# which is `cargo crap`; `--format github` turns each offending function into
# an inline `::warning` annotation before the job fails.
#
# Pull requests only, so `push` has no filter to mirror; recorded in
# scripts/__tests__/workflow-paths-filter-parity.test.mjs.
# The `selector` job decides which crate's gate a pull request runs, and
# `summary` reports the result (ADR 0003).

on:
pull_request:
paths:
- packages/stack-auth/**
- packages/stack-encrypt/**
- packages/stack-encrypt-derive/**
- Cargo.toml
- Cargo.lock
- mise.toml
- mise.test.toml
- .cargo-crap.toml
- .github/workflows/crap-crates.yml
# Keep these excludes last so docs-only changes are skipped.
- "!**.md"
- "!**.example"
# No path filter: a workflow that a path filter skips leaves its required
# checks pending, and GitHub's documentation on required status checks says
# that blocks a merge. The trigger paths are in the `selector` job instead.
pull_request: {}
workflow_dispatch: {}

# Read-only: failures surface as job status and inline annotations.
Expand Down Expand Up @@ -57,8 +47,118 @@ env:
NEXTEST_PROFILE: ci

jobs:
# The selector job: it decides which jobs a pull request runs. It runs on
# every event and is never skipped, because the gated jobs need it. When it
# cannot decide it fails, and that fails the summary job: a selector that
# answered nothing would skip every gated job, and the summary job counts a
# skipped job as passed (ADR 0003).
#
# On a GitHub-hosted runner because the gated jobs wait for it. No checkout:
# on a pull request, dorny/paths-filter lists the changed files through the
# GitHub API.
selector:
name: Selector
runs-on: ubuntu-latest
timeout-minutes: 10
# Each output is named after the filter that decides it.
# `scripts/__tests__/workflow-selectors.test.mjs` follows a job's `if:` to
# its filter by that name.
outputs:
stack_auth: ${{ steps.decide.outputs.stack_auth }}
stack_encrypt: ${{ steps.decide.outputs.stack_encrypt }}
steps:
# Only on a pull request, which has a base to compare with. Other events
# select every job in the step below.
#
# `every` makes a file count only when it matches every rule. That is
# what makes the `!` rules exclude documentation: under dorny's default,
# `some`, a `!` rule matches every other file in the repository. So each
# filter's trigger paths are one brace pattern, written across lines:
# YAML drops a line break that ends in `\`, with the next line's indent.
#
# Each gate scores one crate, and its tests compile that crate's path
# dependencies in the workspace: stack-auth needs stack-profile, and
# stack-encrypt needs stack-encrypt-derive, stack-kms and both of theirs.
# The mise tasks run nextest with the `ci` profile in .config/nextest.toml.
- id: filter
if: github.event_name == 'pull_request'
uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3
with:
predicate-quantifier: every
filters: |
stack_auth:
- "{\
packages/stack-auth/**,\
packages/stack-profile/**,\
Cargo.toml,\
Cargo.lock,\
mise.toml,\
mise.test.toml,\
.cargo-crap.toml,\
.config/nextest.toml,\
.github/workflows/crap-crates.yml\
}"
- "!**/*.md"
- "!**/*.example"
stack_encrypt:
- "{\
packages/stack-encrypt/**,\
packages/stack-encrypt-derive/**,\
packages/stack-kms/**,\
packages/stack-auth/**,\
packages/stack-profile/**,\
Cargo.toml,\
Cargo.lock,\
mise.toml,\
mise.test.toml,\
.cargo-crap.toml,\
.config/nextest.toml,\
.github/workflows/crap-crates.yml\
}"
- "!**/*.md"
- "!**/*.example"

# Explicit default, not `|| 'true'`, which trips GitHub's inconsistent
# treatment of the string 'false'. On a pull request, an answer other than
# `true` or `false` means the filter did not decide, so the step fails
# rather than write it.
#
# The event and the answers arrive through `env:`, not through a `${{ }}`
# in the script. That lets `scripts/__tests__/workflow-selectors.test.mjs`
# run this script and read back what it wrote.
- id: decide
env:
EVENT_NAME: ${{ github.event_name }}
STACK_AUTH: ${{ steps.filter.outputs.stack_auth }}
STACK_ENCRYPT: ${{ steps.filter.outputs.stack_encrypt }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" != "pull_request" ]; then
STACK_AUTH=true
STACK_ENCRYPT=true
fi
answers=(
"stack_auth=$STACK_AUTH"
"stack_encrypt=$STACK_ENCRYPT"
)
for answer in "${answers[@]}"; do
case "${answer#*=}" in
true|false) echo "$answer" >> "$GITHUB_OUTPUT" ;;
*)
echo "::error::The path filter answered '${answer#*=}' for ${answer%%=*}. The selector cannot decide which jobs to run."
exit 1
;;
esac
done

crap-stack-auth:
name: CRAP (stack-auth)
needs: [selector]
# Written as an exclusion, not a list of events: every event other
# than a pull request runs this job.
if: >-
github.event_name != 'pull_request'
|| needs.selector.outputs.stack_auth == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2204
timeout-minutes: 45
steps:
Expand All @@ -83,6 +183,12 @@ jobs:

crap-stack-encrypt:
name: CRAP (stack-encrypt)
needs: [selector]
# Written as an exclusion, not a list of events: every event other
# than a pull request runs this job.
if: >-
github.event_name != 'pull_request'
|| needs.selector.outputs.stack_encrypt == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2204
timeout-minutes: 45
steps:
Expand All @@ -103,3 +209,31 @@ jobs:

- name: Run the CRAP gate
run: mise run crap:stack-encrypt -- --format github

# The summary job: the one job of this workflow that branch rules require.
# It has the same name on every event. It passes when every needed job
# passed or was skipped. Skipped counts as passed because the selector skips
# a job whose trigger paths a change does not touch. A failed job still
# fails this one, because it is in `needs` and reports its failure.
summary:
name: CRAP gate summary
needs: [selector, crap-stack-auth, crap-stack-encrypt]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check that every needed job passed or was skipped
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
set -euo pipefail
echo "Needed results: $RESULTS"
for result in $RESULTS; do
case "$result" in
success|skipped) ;;
*)
echo "::error::A needed job reported '$result'."
exit 1
;;
esac
done
143 changes: 125 additions & 18 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,15 @@ name: Fuzz (crates)
# cache, so coverage compounds; it is minimised with `cargo fuzz cmin`, and
# any crash reproducer is uploaded as an artifact.
#
# cargo-fuzz needs nightly; the tasks run `cargo +nightly fuzz`. Pull requests
# are the only filtered event, so `push` has no filter to mirror; recorded in
# scripts/__tests__/workflow-paths-filter-parity.test.mjs.
# cargo-fuzz needs nightly; the tasks run `cargo +nightly fuzz`. The `selector`
# job decides whether a pull request runs the regression replay, and `summary`
# reports the result (ADR 0003).

on:
pull_request:
paths:
- packages/stack-auth/**
- packages/stack-kms/**
- packages/stack-encrypt/**
- Cargo.toml
- Cargo.lock
- mise.toml
- mise.test.toml
- .github/workflows/fuzz.yml
# Keep these excludes last so docs-only changes are skipped.
- "!**.md"
- "!**.example"
# No path filter: a workflow that a path filter skips leaves its required
# checks pending, and GitHub's documentation on required status checks says
# that blocks a merge. The trigger paths are in the `selector` job instead.
pull_request: {}
schedule:
# Nightly at 04:47 UTC. Scheduled runs fire from the default branch only.
- cron: "47 4 * * *"
Expand Down Expand Up @@ -60,11 +51,99 @@ env:
CARGO_NET_GIT_FETCH_WITH_CLI: true

jobs:
# The selector job: it decides which jobs a pull request runs. It runs on
# every event and is never skipped, because the gated jobs need it. When it
# cannot decide it fails, and that fails the summary job: a selector that
# answered nothing would skip every gated job, and the summary job counts a
# skipped job as passed (ADR 0003).
#
# On a GitHub-hosted runner because the gated jobs wait for it. No checkout:
# on a pull request, dorny/paths-filter lists the changed files through the
# GitHub API.
selector:
name: Selector
runs-on: ubuntu-latest
timeout-minutes: 10
# Each output is named after the filter that decides it.
# `scripts/__tests__/workflow-selectors.test.mjs` follows a job's `if:` to
# its filter by that name.
outputs:
fuzz: ${{ steps.decide.outputs.fuzz }}
steps:
# Only on a pull request, which has a base to compare with. Other events
# select every job in the step below.
#
# `every` makes a file count only when it matches every rule. That is
# what makes the `!` rules exclude documentation: under dorny's default,
# `some`, a `!` rule matches every other file in the repository. So each
# filter's trigger paths are one brace pattern, written across lines:
# YAML drops a line break that ends in `\`, with the next line's indent.
#
# Each fuzz crate builds its parent crate by path, and that crate's path
# dependencies with it: stack-profile and stack-encrypt-derive. Their
# `workspace = true` keys are read from the root Cargo.toml.
- id: filter
if: github.event_name == 'pull_request'
uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3
with:
predicate-quantifier: every
filters: |
fuzz:
- "{\
packages/stack-auth/**,\
packages/stack-profile/**,\
packages/stack-kms/**,\
packages/stack-encrypt/**,\
packages/stack-encrypt-derive/**,\
Cargo.toml,\
Cargo.lock,\
mise.toml,\
mise.test.toml,\
.github/workflows/fuzz.yml\
}"
- "!**/*.md"
- "!**/*.example"

# Explicit default, not `|| 'true'`, which trips GitHub's inconsistent
# treatment of the string 'false'. On a pull request, an answer other than
# `true` or `false` means the filter did not decide, so the step fails
# rather than write it.
#
# The event and the answers arrive through `env:`, not through a `${{ }}`
# in the script. That lets `scripts/__tests__/workflow-selectors.test.mjs`
# run this script and read back what it wrote.
- id: decide
env:
EVENT_NAME: ${{ github.event_name }}
FUZZ: ${{ steps.filter.outputs.fuzz }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" != "pull_request" ]; then
FUZZ=true
fi
answers=(
"fuzz=$FUZZ"
)
for answer in "${answers[@]}"; do
case "${answer#*=}" in
true|false) echo "$answer" >> "$GITHUB_OUTPUT" ;;
*)
echo "::error::The path filter answered '${answer#*=}' for ${answer%%=*}. The selector cannot decide which jobs to run."
exit 1
;;
esac
done

fuzz-regression:
name: fuzz regression (${{ matrix.slug }})
needs: [selector]
# Not on the nightly schedule, which is the campaign's. A manual dispatch
# runs both jobs.
if: github.event_name != 'schedule'
# runs both jobs. On a pull request, the selector decides. Written as
# exclusions, not a list of events.
if: >-
github.event_name != 'schedule'
&& (github.event_name != 'pull_request'
|| needs.selector.outputs.fuzz == 'true')
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
strategy:
Expand Down Expand Up @@ -189,3 +268,31 @@ jobs:
name: fuzz-artifacts-${{ matrix.slug }}
path: ${{ matrix.dir }}/fuzz/artifacts/**
if-no-files-found: ignore

# The summary job: the one job of this workflow that branch rules require.
# It has the same name on every event. It passes when every needed job
# passed or was skipped. Skipped counts as passed because the selector skips
# a job whose trigger paths a change does not touch. A failed job still
# fails this one, because it is in `needs` and reports its failure.
summary:
name: Fuzz summary
needs: [selector, fuzz-regression, fuzz-campaign]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check that every needed job passed or was skipped
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
set -euo pipefail
echo "Needed results: $RESULTS"
for result in $RESULTS; do
case "$result" in
success|skipped) ;;
*)
echo "::error::A needed job reported '$result'."
exit 1
;;
esac
done
Loading
Loading