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
180 changes: 156 additions & 24 deletions .github/workflows/tests-golang.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,34 +23,20 @@ name: Tests (Go)
# Every job except go-binding-cross runs mise at the repository root. The
# macOS and Windows jobs install only Go, reading the version from the root
# mise.toml, so they need no Rust.
#
# The `selector` job decides which jobs a pull request runs, and `summary`
# reports the result (ADR 0003). go-binding-cross and live need wasi-check,
# so they run when it runs.

on:
pull_request:
paths:
- packages/stack-auth/**
- packages/stack-profile/**
- packages/stack-kms/**
- packages/stack-encrypt/**
- packages/stack-encrypt-derive/**
- packages/stack-guest-abi/**
# The eql guest build links eql-bindings.
- packages/eql/crates/eql-bindings/**
- packages/eql/crates/eql-domains/**
- languages/golang/**
- scripts/check-wasm-imports.py
- scripts/go-binding-test.sh
- Cargo.toml
- Cargo.lock
- .cargo/**
- mise.toml
- mise.test.toml
- .github/actions/require-cs-secrets/**
- .github/workflows/tests-golang.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: {}
push:
branches: [main]
# The union of the `selector` job's filters.
# scripts/__tests__/workflow-paths-filter-parity.test.mjs compares them.
paths:
- packages/stack-auth/**
- packages/stack-profile/**
Expand Down Expand Up @@ -93,8 +79,120 @@ 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:
go: ${{ steps.decide.outputs.go }}
go_lint: ${{ steps.decide.outputs.go_lint }}
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.
- id: filter
if: github.event_name == 'pull_request'
uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3
with:
predicate-quantifier: every
filters: |
# wasi-check builds the stack crates and both guests, and runs the Go
# tests against them. The eql guest build links eql-bindings, which
# builds eql-domains. live needs wasi-check, and also runs the
# require-cs-secrets action.
go:
- "{\
packages/stack-auth/**,\
packages/stack-profile/**,\
packages/stack-kms/**,\
packages/stack-encrypt/**,\
packages/stack-encrypt-derive/**,\
packages/stack-guest-abi/**,\
packages/eql/crates/eql-bindings/**,\
packages/eql/crates/eql-domains/**,\
languages/golang/**,\
scripts/check-wasm-imports.py,\
scripts/go-binding-test.sh,\
Cargo.toml,\
Cargo.lock,\
.cargo/**,\
mise.toml,\
mise.test.toml,\
.github/actions/require-cs-secrets/**,\
.github/workflows/tests-golang.yml\
}"
- "!**/*.md"
- "!**/*.example"
# golangci-lint over the Go module, with its config in
# languages/golang/.golangci.yaml. mise.toml pins go and golangci-lint.
# It needs no guests.
go_lint:
- "{\
languages/golang/**,\
mise.toml,\
.github/workflows/tests-golang.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 }}
GO: ${{ steps.filter.outputs.go }}
GO_LINT: ${{ steps.filter.outputs.go_lint }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" != "pull_request" ]; then
GO=true
GO_LINT=true
fi
answers=(
"go=$GO"
"go_lint=$GO_LINT"
)
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

wasi-check:
name: WASI check, guests and Go (Linux)
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.go == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2204
timeout-minutes: 60
env:
Expand Down Expand Up @@ -242,6 +340,12 @@ jobs:
# guests: the packages embed a directory and compile without them.
go-lint:
name: Go lint
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.go_lint == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
steps:
Expand Down Expand Up @@ -401,3 +505,31 @@ jobs:
echo "==> $example"
cargo run --locked -p stack-encrypt --example "$example"
done

# 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: Tests (Go) summary
needs: [selector, wasi-check, go-lint, go-binding-cross, live]
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
41 changes: 27 additions & 14 deletions scripts/__tests__/auth-binding-step-order.test.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { filterCovers } from './lib/paths-filter.mjs'
import { jobTriggerLists } from './lib/selector.mjs'
import { readWorkflow, workflowFiles } from './lib/workflows.mjs'

/**
Expand Down Expand Up @@ -84,13 +85,20 @@ describe('the @cipherstash/auth binding is built wherever the SDK runs', () => {

const same = (entry, input) => entry === input

/** The `paths:` lists of a workflow's filtered events. */
const filters = (relPath) => {
const wf = readWorkflow(relPath)
const on = wf?.on ?? wf?.[true]
return ['push', 'pull_request']
.map((event) => on?.[event]?.paths)
.filter(Array.isArray)
/**
* The path lists that decide whether the given jobs run, once each: the
* workflow's `push` and `pull_request` `paths:` lists, and the selector filter
* each job reads. Every job of the workflow when none are given.
*/
const filters = (relPath, jobNames) => {
const lists = new Map()
for (const jobName of jobNames ??
Object.keys(readWorkflow(relPath)?.jobs ?? {})) {
for (const { source, paths } of jobTriggerLists(relPath, jobName)) {
lists.set(source, paths)
}
}
return [...lists.values()]
}

/**
Expand All @@ -105,14 +113,19 @@ const AUTH_SOURCE_WORKFLOWS = [
]

describe('a change to the auth binding starts the jobs that load it', () => {
const users = [
...new Set(
JOBS.filter((job) => job.auth).map((job) => job.id.split(' / ')[0]),
),
]
/** Each workflow with a job that builds the binding, and those jobs. */
const usersByWorkflow = new Map()
for (const job of JOBS.filter((entry) => entry.auth)) {
const [relPath, jobName] = job.id.split(' / ')
usersByWorkflow.set(relPath, [
...(usersByWorkflow.get(relPath) ?? []),
jobName,
])
}
const users = [...usersByWorkflow.keys()]

it.each(users)('%s filters on the action', (relPath) => {
for (const paths of filters(relPath)) {
for (const paths of filters(relPath, usersByWorkflow.get(relPath))) {
expect(
filterCovers(paths, '.github/actions/build-auth-binding/**', same),
).toBe(true)
Expand All @@ -133,7 +146,7 @@ describe('a change to the auth binding starts the jobs that load it', () => {
'%s filters on the auth sources',
(relPath) => {
expect(shipped.length).toBeGreaterThanOrEqual(4)
const lists = filters(relPath)
const lists = filters(relPath, usersByWorkflow.get(relPath))
expect(lists.length).toBe(2)
for (const paths of lists) {
expect(
Expand Down
50 changes: 50 additions & 0 deletions scripts/__tests__/golang-selector.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { readSelectorJob, selectedJobs } from './lib/selector.mjs'

/**
* What the Tests (Go) selector job decides for a pull request.
*
* `wasi-check` builds the stack crates and both WASI guests, and runs the Go
* tests against them. `go-binding-cross` and `live` need it, so they run when
* it runs. `go-lint` needs no guests, so only the Go module and its tool pins
* start it.
*/

const WORKFLOW = '.github/workflows/tests-golang.yml'

const run = (files) =>
selectedJobs(WORKFLOW, files).filter(
(id) => id !== 'selector' && id !== 'summary',
)

const ALL = ['go-binding-cross', 'go-lint', 'live', 'wasi-check']
const GUEST_CHAIN = ['go-binding-cross', 'live', 'wasi-check']

describe('the Tests (Go) selector job', () => {
it('exists', () => {
expect(readSelectorJob(WORKFLOW)).not.toBeNull()
})

it('runs every job for a change to the Go module', () => {
expect(run(['languages/golang/encrypt/encrypt.go'])).toEqual(ALL)
expect(run(['mise.toml'])).toEqual(ALL)
})

it('skips the lint for a change to a crate the guests build', () => {
expect(run(['packages/stack-encrypt/src/lib.rs'])).toEqual(GUEST_CHAIN)
expect(run(['packages/eql/crates/eql-bindings/src/lib.rs'])).toEqual(
GUEST_CHAIN,
)
expect(run(['.github/actions/require-cs-secrets/action.yml'])).toEqual(
GUEST_CHAIN,
)
})

it('skips every job for documentation and unrelated code', () => {
expect(run(['languages/golang/README.md'])).toEqual([])
expect(run(['packages/eql/src/v3/schema.sql'])).toEqual([])
expect(run(['languages/typescript/packages/stack/src/index.ts'])).toEqual(
[],
)
})
})
32 changes: 19 additions & 13 deletions scripts/__tests__/integration-workflow-paths.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { REPO_ROOT } from './lib/repo-root.mjs'
import { jobTriggerLists } from './lib/selector.mjs'
import { readWorkflow, workflowFiles } from './lib/workflows.mjs'

/**
Expand Down Expand Up @@ -40,9 +41,6 @@ const STACK_SRC = 'languages/typescript/packages/stack/src'
const STACK_MANIFEST = 'languages/typescript/packages/stack/package.json'
const CATALOG_MANIFEST = 'pnpm-workspace.yaml'

/** Both trigger events, in the order GitHub evaluates them. */
const TRIGGER_EVENTS = ['push', 'pull_request']

/**
* The integration workflows, discovered rather than listed: any workflow whose
* `CS_IT_SUITE` globs select suites out of `languages/typescript/packages/stack/` is in scope. A
Expand All @@ -56,14 +54,21 @@ function discoverWorkflows() {
}

/**
* `on:` parses as the boolean `true` under YAML 1.1 (the "Norway problem"),
* which is why this reads both keys rather than `wf.on`.
* The path lists that decide whether the jobs running a suite run, once each:
* the workflow's `push` and `pull_request` `paths:` lists, and the selector
* filter each such job reads.
*/
function triggerBlocks(wf) {
const on = wf.on ?? wf[true]
return TRIGGER_EVENTS.map((event) => on?.[event]).filter(
(block) => block && Array.isArray(block.paths),
)
function triggerBlocks(relPath) {
const lists = new Map()
for (const [jobId, job] of Object.entries(
readWorkflow(relPath)?.jobs ?? {},
)) {
if (!job?.env?.CS_IT_SUITE) continue
for (const { source, paths } of jobTriggerLists(relPath, jobId)) {
lists.set(source, { source, paths })
}
}
return [...lists.values()]
}

/** Every `CS_IT_SUITE` glob set declared anywhere in the workflow. */
Expand Down Expand Up @@ -190,8 +195,9 @@ describe('integration workflow paths filters', () => {
for (const relPath of WORKFLOWS) {
it(`${relPath} triggers on the manifests pinning its suites' dependencies`, () => {
const wf = readWorkflow(relPath)
const blocks = triggerBlocks(wf)
expect(blocks.length).toBe(TRIGGER_EVENTS.length)
const blocks = triggerBlocks(relPath)
// A `push` list, and a `pull_request` list or a selector filter.
expect(blocks.length).toBe(2)

const deps = stackDependencies()
const files = suiteFiles(suiteGlobs(wf))
Expand All @@ -215,7 +221,7 @@ describe('integration workflow paths filters', () => {

it(`${relPath} triggers on every source path its suites import`, () => {
const wf = readWorkflow(relPath)
const blocks = triggerBlocks(wf)
const blocks = triggerBlocks(relPath)
expect(blocks.length).toBeGreaterThan(0)

const files = suiteFiles(suiteGlobs(wf))
Expand Down
Loading
Loading