CI/CD Pipeline
This document describes the continuous integration and release pipeline for the Parsl Ephemeral Provider.
The authoritative definitions are the workflow files themselves —
.github/workflows/ci.yml and
.github/workflows/release.yml. This page
explains why each job exists and how to run the same checks locally; it
deliberately does not inline the YAML, because the copy that used to live here
drifted out of sync with the workflow it documented.
Two workflows
Workflow |
Trigger |
Purpose |
|---|---|---|
|
push to |
lint, type-check, tests, build, docs |
|
push of a |
version check, tests, build, GitHub release, PyPI |
ci.yml replaced a near-duplicate ci.yml + ci-cd.yml pair that between them
ran the same unit suite five times, over Python versions the package no longer
supports. Everything now runs through uv — uv sync --locked installs from the
committed uv.lock, so CI resolves the same dependency set a developer’s
checkout does.
ci.yml jobs
lint
ruff check and ruff format --check, plus a bandit security scan.
Two details worth knowing:
Scope is
., the whole repository. It was once narrowed toparsl_ephemeral_provider tests, becausetools/carried bare excepts and unused imports in one-off debug scripts. #93 and #165 pruned those in v0.8.0, so the narrowing no longer protects anything and a.-scoped check passes.ruff-format, not black.
blackandisortare no longer dependencies. The repo formats withruff-formatat its default 88-character line length; the old[tool.black] line-length = 100disagreed with how every file was actually formatted, so running black reformatted ~80 unrelated lines. Theruff-pre-commithook is pinned to the same ruff versionuv.lockresolves, so a local pre-commit pass and the CI check cannot demand opposite output.
type-check
mypy parsl_ephemeral_provider, reported but not gated (continue-on-error: true)
while the pre-existing error count is worked down under
#81 and
#82. Remove the
continue-on-error once it reaches zero.
unit-tests
Runs tests/unit and tests/security on Python 3.10, 3.11, and 3.12.
requires-python is >=3.10 — Parsl 2026.x dropped 3.9, so the old 3.8/3.9
matrix entries could not have installed the package at all.
tests/security is pure-mock and marked unit, so it belongs to this job rather
than a separate one.
Selection is by path, not -m unit. That distinction caused a long-running
divergence: the Makefile selected -m unit and collected only a fraction of the
suite, passing, while CI ran the unmarked set and failed. Selecting by path means a
newly-added file without a marker still runs — and the two now agree, because every
file under tests/unit and tests/security carries the marker (1,211 collected
either way).
This job carries the project’s real coverage gate, --cov-fail-under=65. The
--cov-fail-under in pyproject.toml is a lower smoke floor because addopts
applies to every invocation, including narrow ones — pytest tests/integration
alone measures 34%.
integration-tests
Runs tests/integration against a pinned substrate service container
(ghcr.io/scttfrdmn/substrate), plus a gated tests/test_substrate_emulation.py
conformance step. Substrate replaced LocalStack in
#125: LocalStack OSS
is end-of-life and its latest community tag now resolves to the Pro build, which
exits 55 without a license token – before any step runs, and outside what
continue-on-error covers.
The integration suite gates as of
#192. It carried
continue-on-error: true for as long as 46 mode constructions across 9 files
omitted the network IDs #69
made required, since gating before that was fixed would have turned every PR red on
known debt. #92 closed
in v0.8.0 and the suite is green, so the exemption could only have hidden a
regression. Only the Codecov upload step remains non-gating.
Note that a pytest marker only selects tests — it never skips them. Each
emulator-backed file pairs its markers with a skipif(not is_substrate_available())
guard; without one, a plain pytest tests/integration errors instead of skipping.
aws-e2e-tests
The real-AWS E2E suite (tests/aws), manual dispatch only — it bills money
and needs a pre-provisioned VPC, subnet, and security group.
#60 closed with tests
here that no workflow referenced; there are 87 now, across ten files.
Credentials come from OIDC via aws-actions/configure-aws-credentials, so no
long-lived access keys live in secrets. Configure:
Secret
AWS_E2E_ROLE_ARN— the role the workflow assumes.Variables
AWS_TEST_REGION,AWS_TEST_VPC_ID,AWS_TEST_SUBNET_ID,AWS_TEST_SG_ID.
The job is gated on vars.AWS_TEST_REGION != '', so a dispatch on a repository
without these configured skips rather than failing. The gate is what makes that
true, not tests/aws/conftest.py: conftest does skip when the three IDs are
unset, but pytest is never reached — configure-aws-credentials fails first on
the empty region, so before
#161 every dispatch
was red.
Once it does run, conftest validates the IDs against AWS_TEST_REGION up front —
IDs from another region otherwise surface minutes in, from deep inside
RunInstances, after instances have been billed. Pick a subnet in an AZ that
offers your instance type (us-east-1e does not offer t3.micro).
A final always() step runs parsl-ephemeral-cleanup --dry-run to report
orphans, since a failed test is exactly when instances are most likely to be left
running. It reports without deleting, so CI never mutates a shared account. The
script takes credentials from the boto3 chain — the OIDC credentials the earlier
step exported — rather than a named profile; it previously defaulted to the local
aws profile and died here on “The config profile (aws) could not be found”.
test-bats
Runs bats tests/bats/ for the shell scripts under scripts/.
build
uv build, then twine check. Also asserts the CloudFormation templates are
present in the built wheel: before
#112,
get_cf_template() resolved templates by filesystem path, so a wheel that omitted
them failed only at runtime, on a real AWS call.
docs
Builds the Sphinx documentation and uploads it as an artifact.
release.yml
Triggered by pushing a v* tag. Before anything else it verifies the tag
matches parsl_ephemeral_provider.__version__ — bump-my-version has silently
missed __init__.py before (its [tool.bumpversion] search string drifted out of
sync), and v0.6.0 shipped with __version__ == "0.1.0". Catching that here
matters because a PyPI version can never be reused. make version-verify runs the
same check locally, and the version-bump-* targets call it automatically.
PyPI publishing uses trusted publishing
via OIDC, so no PYPI_API_TOKEN secret is needed. The GitHub release is created
with gh release create; actions/create-release and
actions/upload-release-asset are both archived and unmaintained.
The old ci-cd.yml carried a second build-and-publish job on
release: published, so a tag push followed by a published release ran two
independent PyPI uploads. This is now the only publish path.
Running the same checks locally
The Makefile runs exactly what CI runs, through uv:
make lint-python # ruff check + ruff format --check
make type-check # mypy
make test-unit # tests/unit + tests/security, with the 65% gate
make test-integration # starts substrate, then tests/integration
make test-aws # tests/aws against real AWS (prompts first; costs money)
make build # uv build
make version-verify # pyproject and __init__ versions agree
Never invoke pytest, ruff, or mypy bare — they resolve against whatever is
on PATH rather than .venv. Use uv run (or the Makefile, which does).
Badges
[](https://github.com/scttfrdmn/parsl-ephemeral-provider/actions/workflows/ci.yml)
[](https://codecov.io/gh/scttfrdmn/parsl-ephemeral-provider)
[](https://badge.fury.io/py/parsl-ephemeral-provider)
SPDX-License-Identifier: Apache-2.0 SPDX-FileCopyrightText: 2025-2026 Scott Friedman and Project Contributors