CI/CD Pipelines¶
Overview¶
CI/CD for Artifact-ML relies on GitHub actions.
The github actions workflows powering our CI/CD pipeline delegate to shell scripts.
The latter are organized under the .github/scripts directory.
All scripts are unit-tested using the Bats framework.
Tests are organized in .github/tests. Their directory structure mirrors that of .github/scripts.
GitHub Actions Workflows¶
Workflow Name Convention¶
All Github Actions workflows follow the naming convention:
AREA_TRIGGER[SCOPE]
- AREA – high level operation spec (e.g.
CI,ENFORCE,LINT,PUBLISH). - TRIGGER – event that triggers the workflow (
PUSH,PR,SCHEDULE). - SCOPE – relevant component or branch (
CORE,EXPERIMENT,TORCH).
GitHub Actions Workflow Registry¶
Push Triggers¶
Feature Branch CI (path-filtered)¶
ci_push_core.yml(workflow name: CI_PUSH[CORE]): runs CI checks when changes toartifact-core/**are pushed to any branch exceptmain.ci_push_experiment.yml(workflow name: CI_PUSH[EXPERIMENT]): runs CI checks when changes toartifact-experiment/**are pushed to any branch exceptmain.ci_push_torch.yml(workflow name: CI_PUSH[TORCH]): runs CI checks when changes toartifact-torch/**are pushed to any branch exceptmain.
Note: These workflows skip execution if an open PR exists for the branch (to avoid duplicate CI runs). For example, CI_PUSH[CORE] on dev-core skips if a dev-core → main PR is open (since CI_PR[MAIN] will handle testing).
Branch: main¶
ci_push_main.yml(workflow name: CI_PUSH[MAIN]): unified post-merge workflow with the following job chain:validate-and-extract: single job with 4 steps: (a) lints merge commit message, (b) lints merge commit description, (c) extracts component name viaget_component_name.sh, (d) extracts bump type viaget_bump_type.sh. Outputscomponentandbump_type.ci-component(needs: validate-and-extract): runs CI checks (lint, test, Codecov, build) for all components.bump-version(needs: ci-component, validate-and-extract): callsjob.shdirectly with the extracted component and bump type to updatepyproject.tomland push version tag (format:<component>-v<version>, e.g.,core-v1.2.3). Skipped if component is root or bump type is no-bump.publish(needs: bump-version, validate-and-extract): triggersPUBLISH[PYPI]viagh workflow runwith the component name after bump completes successfully. The publish workflow reads the current version from the component'spyproject.tomland creates a GitHub Release for the version tag that was pushed by bump-version.
Manual Workflows (workflow_dispatch only)¶
Version Bumping¶
bump_component.yml(workflow name: BUMP_COMPONENT): manual fallback workflow for version bumping viaworkflow_dispatchonly—acceptscomponentandbump_typeas inputs, updates the relevantpyproject.toml, and pushes a git tag (format:<component>-v<version>, e.g.,core-v1.2.3). Note: Version bumping normally happens automatically viaCI_PUSH[MAIN]after PR merge. This workflow is provided as a fallback should the need arise (e.g., CI_PUSH[MAIN] bump-version job failure requiring manual intervention, or testing version bump logic in isolation).
Publishing¶
publish.yml(workflow name: PUBLISH[PYPI]): publishes packages to PyPI viaworkflow_dispatchonly—triggered explicitly byCI_PUSH[MAIN]after bump-version completes (viagh workflow run), or manually via the Actions UI. Consists of two jobs: (1)get-version: extracts the current version from the component'spyproject.tomlusingget_version_from_pyproject.sh; (2)publish: builds the package using Poetry, publishes to PyPI using Trusted Publishing (OIDC), and creates a GitHub Release for the existing version tag (format:<component>-v<version>, e.g.,core-v1.2.3) that was created by the bump-version job, attaching the built artifacts and auto-generated release notes.publish_test.yml(workflow name: PUBLISH[TEST_PYPI]): publishes packages to TestPyPI when manually triggered via workflow dispatch. Single job that builds the package using Poetry and publishes to TestPyPI using Trusted Publishing (OIDC) for testing purposes before production release.
PR Triggers¶
dev-core¶
ci_pr_dev_core.yml(workflow name: CI_PR[DEV_CORE]): runs full CI (lint, test with coverage, Codecov, SonarCloud, build) for PRs targetingdev-core(pre-merge gating). Skips CI ifartifact-core/has no changes (usescheck_component_changed.sh),enforce_branch_naming_pr_dev_core.yml(workflow name: ENFORCE_BRANCH_NAMING_PR[DEV_CORE]): ensures that branches being PR'd todev-corefollow the naming convention:feature-core/<descriptive_name>,fix-core/<descriptive_name>,enforce_change_dirs_pr_dev_core.yml(workflow name: ENFORCE_CHANGE_DIRS_PR[DEV_CORE]): ensures PRs todev-coreonly modify files in their corresponding directories,
dev-experiment¶
ci_pr_dev_experiment.yml(workflow name: CI_PR[DEV_EXPERIMENT]): runs full CI (lint, test with coverage, Codecov, SonarCloud, build) for PRs targetingdev-experiment(pre-merge gating). Skips CI ifartifact-experiment/has no changes (usescheck_component_changed.sh),enforce_branch_naming_pr_dev_experiment.yml(workflow name: ENFORCE_BRANCH_NAMING_PR[DEV_EXPERIMENT]): ensures that branches being PR'd todev-experimentfollow the naming convention:feature-experiment/<descriptive_name>,fix-experiment/<descriptive_name>,enforce_change_dirs_pr_dev_experiment.yml(workflow name: ENFORCE_CHANGE_DIRS_PR[DEV_EXPERIMENT]): ensures PRs todev-experimentonly modify files in their corresponding directories,
dev-torch¶
ci_pr_dev_torch.yml(workflow name: CI_PR[DEV_TORCH]): runs full CI (lint, test with coverage, Codecov, SonarCloud, build) for PRs targetingdev-torch(pre-merge gating). Skips CI ifartifact-torch/has no changes (usescheck_component_changed.sh),enforce_branch_naming_pr_dev_torch.yml(workflow name: ENFORCE_BRANCH_NAMING_PR[DEV_TORCH]): ensures that branches being PR'd todev-torchfollow the naming convention:feature-torch/<descriptive_name>,fix-torch/<descriptive_name>,enforce_change_dirs_pr_dev_torch.yml(workflow name: ENFORCE_CHANGE_DIRS_PR[DEV_TORCH]): ensures PRs todev-torchonly modify files in their corresponding directories,
main¶
ci_pr_main.yml(workflow name: CI_PR[MAIN]): runs full CI (lint, test with coverage, Codecov, SonarCloud, build) for PRs targetingmain(pre-merge gating). Skips CI for components that have no changes (usescheck_component_changed.shto detect),lint_title_pr_main.yml(workflow name: LINT_TITLE_PR[MAIN]): ensures PR titles tomainfollow the appropriate semantic versioning prefix convention (see Versioning and PRs tomain),enforce_branch_naming_pr_main.yml(workflow name: ENFORCE_BRANCH_NAMING_PR[MAIN]): ensures that branches being PR'd tomainfollow the naming convention:dev-<component>,hotfix-<component>/*, orsetup-<component>/*enforce_change_dirs_pr_main.yml(workflow name: ENFORCE_CHANGE_DIRS_PR[MAIN]) - Ensures:- PRs from
dev-coretomainonly modify files in theartifact-coredirectory - PRs from
dev-experimenttomainonly modify files in theartifact-experimentdirectory - PRs from
dev-torchtomainonly modify files in theartifact-torchdirectory - PRs from
hotfix-core/*branches tomainonly modify files in theartifact-coredirectory - PRs from
hotfix-experiment/*branches tomainonly modify files in theartifact-experimentdirectory - PRs from
hotfix-torch/*branches tomainonly modify files in theartifact-torchdirectory - PRs from
hotfix-root/*orsetup-root/*branches tomainonly modify files outside the component source code directories (artifact-core/artifact_core/,artifact-experiment/artifact_experiment/,artifact-torch/artifact_torch/).
Branch Protection Rulesets¶
main-protection Ruleset¶
Target branch pattern: main
| Rule | Setting |
|---|---|
| Restrict deletions | ✅ Enabled |
| Require pull request before merging | ✅ Enabled |
| Required approvals | 0 (configurable) |
| Require status checks to pass | ✅ Enabled |
| Block force pushes | ✅ Enabled |
Required status checks:
| Check Name | Source Workflow |
|---|---|
ci-component (artifact-core, artifact_core, core) |
CI_PR[MAIN] |
ci-component (artifact-experiment, artifact_experiment, experiment) |
CI_PR[MAIN] |
ci-component (artifact-torch, artifact_torch, torch) |
CI_PR[MAIN] |
lint-pr-title |
LINT_TITLE_PR[MAIN] |
enforce-branch-naming |
ENFORCE_BRANCH_NAMING_PR[MAIN] |
enforce-change-dirs |
ENFORCE_CHANGE_DIRS_PR[MAIN] |
Note: SonarCloud and Codecov are integrated into the ci-component job—no separate status checks needed.
Note: For matrix jobs, use the job name with matrix values (e.g., ci-component (artifact-core, artifact_core, core)), NOT the workflow name prefix. Use GitHub's autocomplete when adding required checks.
dev-branches-protection Ruleset¶
Target branch pattern: dev-* (covers dev-core, dev-experiment, dev-torch)
| Rule | Setting |
|---|---|
| Restrict deletions | ✅ Enabled |
| Require pull request before merging | ✅ Enabled |
| Required approvals | 0 (configurable) |
| Require status checks to pass | ✅ Enabled |
| Block force pushes | ✅ Enabled |
| Bypass list | Repository admins (allows force push for rebasing) |
Required status checks:
| Check Name | Source Workflow |
|---|---|
ci-component |
CI_PR[DEV_CORE], CI_PR[DEV_EXPERIMENT], CI_PR[DEV_TORCH] |
enforce-branch-naming |
ENFORCE_BRANCH_NAMING_PR[DEV_*] |
enforce-change-dirs |
ENFORCE_CHANGE_DIRS_PR[DEV_*] |
Note: SonarCloud and Codecov are integrated into the ci-component job—no separate status checks needed.
Note: For dev-* branches, each component has its own CI workflow. The ruleset pattern dev-* covers all three. Since dev workflows don't use a matrix, the check name is simply ci-component.
CICD Scripts¶
The github actions workflows powering our CI/CD pipeline delegate to shell scripts.
The latter are organized under the .github/scripts directory.
Execution Context¶
All scripts are designed to run from the repository root.
This means:
- Workflow files (
.github/workflows/*.yml) execute scripts using paths relative to the repository root (e.g.,.github/scripts/linting/check_is_merge_commit.sh), - Scripts reference other scripts using paths relative to the repository root (e.g.,
.github/scripts/linting/lint_commit_description.sh), - CICD script functional tests run scripts from the repository root context.
This approach aligns with GitHub Actions' standard execution context, where workflows run from the repository root.
Script Registry¶
Linting Scripts (.github/scripts/linting/)¶
check_is_merge_commit.sh:- Given: the currently checked-out commit (typically
$GITHUB_SHA/HEAD). - Does: counts parent commits; if >1, it’s a merge commit. Prints the parent count to stdout.
-
Outcome: exits
0for merge commits (multi-parent),1otherwise. -
detect_bump_pattern.sh: - Given: a text string (e.g., PR title or commit body).
- Does: lowercases the text and checks if it starts with a
bump_typeprefix i.e.patch:,minor:,major:,no-bump:or their scoped counterparts e.g.patch(scope):. -
Outcome: prints the bump type (
patch|minor|major|no-bump) to stdout; exits1if no valid prefix. -
extract_branch_info.sh: - Given: a branch name following the repository’s branch-naming convention.
- Does: validates the shape and parses
branch_typeandcomponent_name. Rules:dev-<component>(no trailing/…allowed)<branch_type>-<component>/<descriptive-name>for non-dev types
- Outcome: prints JSON
{"branch_type":"…","component_name":"…"}to stdout on success; exits1if the branch name doesn’t follow one of the valid shapes. -
Examples:
dev-core-->{"branch_type":"dev","component_name":"core"}dev-experiment-->{"branch_type":"dev","component_name":"experiment"}dev-torch-->{"branch_type":"dev","component_name":"torch"}hotfix-core/fix-ci-->{"branch_type":"hotfix","component_name":"core"}hotfix-torch/patch-loader-crash-->{"branch_type":"hotfix","component_name":"torch"}setup-core/seed-->{"branch_type":"setup","component_name":"core"}setup-experiment/init-config-->{"branch_type":"setup","component_name":"experiment"}feature-torch/add-dataloader-->{"branch_type":"feature","component_name":"torch"}feature-core/improve-logging-->{"branch_type":"feature","component_name":"core"}fix-core/harden-ci-->{"branch_type":"fix","component_name":"core"}fix-experiment/typo-in-docs-->{"branch_type":"fix","component_name":"experiment"}
-
lint_branch_name.sh: - Given:
<branch_name>and optional space-separated lists:<ALLOWED_COMPONENTS>(default:root core experiment torch)<ALLOWED_BRANCH_TYPES>(default:dev hotfix setup)
- Does:
1) Calls
extract_branch_info.shto validate the branch shape and parsebranch_type+component_name. Shape rules:dev-<component>(no trailing/…allowed)<branch_type>-<component>/<descriptive-name>for non-dev types (e.g.,hotfix,setup; plus any others your extractor supports) 2) Verifiesbranch_type ∈ ALLOWED_BRANCH_TYPESandcomponent_name ∈ ALLOWED_COMPONENTS.
-
Outcome:
- Success (
exit 0) → prints the parsed JSON to stdout (e.g.{"branch_type":"dev","component_name":"core"}) - Failure (
exit 1) → prints guidance (allowed components/types and example shapes) to stderr.
- Success (
-
lint_pr_title.sh: - Given:
"PR Title"and optionally[branch_name]. - Does: enforces that the title starts with a
bump_typeprefix (patch:,minor:,major:,no-bump:or their scoped counterparts e.g.patch(scope):). If abranch_nameis provided and its component parses toroot, then onlyno-bump:is allowed. -
Outcome: prints the
bump_typeto stdout on success; exits1with a clear message if the prefix is missing/invalid or the root rule is violated. -
lint_commit_description.sh: - Given: the body/description of the last commit (merge commit in typical PR merges).
- Does: ensures the description begins with a semantic prefix by passing it to
detect_bump_pattern.sh. -
Outcome: prints the resolved bump type to stdout (
patch|minor|major|no-bump) and exits0; if empty or missing the prefix, prints errors and exits1. -
lint_commit_message.sh: - Given: the subject of the last commit (expected GitHub merge subject like
Merge pull request #123 from user/branchor... user:branch). - Does: extracts the
branchfrom the subject and validates its naming viaextract_branch_info.sh. -
Outcome: prints the
component_nameto stdout on success; exits1if the subject isn’t a merge format or the branch naming is invalid. -
lint_merge_commit_description.sh: - Given: current commit context (CI).
- Does: confirms the commit is a merge commit (
check_is_merge_commit.sh), then validates the merge commit description by invokinglint_commit_description.sh. -
Outcome: prints
bump_typeto stdout on success; exits1if not a merge commit or the description/prefix validation fails. -
lint_merge_commit_message.sh: - Given: current commit context (CI).
- Does: verifies that the current commit is a merge commit (
check_is_merge_commit.sh), then validates the merge commit subject by invokinglint_commit_message.sh. - Outcome: prints the parsed component_name to stdout on success; exits
1if the commit isn’t a merge or if the subject validation fails.
Publishing Scripts (.github/scripts/publishing/)¶
extract_component_from_tag.sh:- Given:
<event_name>(workflow_dispatch),<ref_name_or_tag>(unused for workflow_dispatch), and<manual_component_input>(component name). - Does: extracts the component name from workflow_dispatch input. (Tag parsing code is retained for backward compatibility but not used since
PUBLISH[PYPI]only uses workflow_dispatch.) - Outcome: prints JSON object with
componentandversionfields to stdout (e.g.,{"component":"core","version":"manual"}); exits1with::error::prefixed diagnostics if validation fails or required parameters are missing. -
Examples:
extract_component_from_tag.sh "workflow_dispatch" "" "experiment"→{"component":"experiment","version":"manual"}
-
get_version_from_pyproject.sh: - Given:
<component>(component name:core,experiment, ortorch). - Does: validates the component name, locates the component's
pyproject.tomlfile (e.g.,artifact-core/pyproject.toml), and extracts the version field using regex pattern matching. - Outcome: prints the version string to stdout (e.g.,
1.2.3); exits1with::error::prefixed diagnostics if the component is invalid,pyproject.tomlis missing, or the version field cannot be extracted or has invalid format. - Usage: used by
PUBLISH[PYPI]andPUBLISH[TEST_PYPI]workflows to read the current version from the component'spyproject.tomlafter version bump. - Examples:
get_version_from_pyproject.sh "core"→1.2.3get_version_from_pyproject.sh "experiment"→0.5.1
Path Enforcement Scripts (.github/scripts/enforce_path/)¶
check_component_changed.sh:- Given:
<component_dir>(e.g.,artifact-core) and optional[base_ref](default:HEAD~1). - Does: checks if any files in the component directory changed between the base ref and HEAD using
git diff. - Outcome: prints
trueif changes exist,falseotherwise; exits0on success,1on missing arguments. -
Usage: used by PR CI workflows (
CI_PR[MAIN],CI_PR[DEV_*]) to skip CI/SonarCloud/Codecov for components that have no changes (prevents "0% coverage on new code" errors). -
enforce_change_dirs_main.sh: - Given:
<head_ref>(source branch) and<base_ref>(target branch, should bemain). - Does: routes to the appropriate directory enforcement check based on the source branch pattern:
dev-<component>→ changes must be inartifact-<component>/hotfix-<component>/*→ changes must be inartifact-<component>/*-root/*(setup-root, hotfix-root) → changes must be outside component source directories
- Outcome: exits
0if directory check passes,1if check fails or branch pattern is unrecognized. -
Usage: used by
ENFORCE_CHANGE_DIRS_PR[MAIN]workflow to enforce directory restrictions on PRs to main. -
ensure_changed_files_in_dir.sh: - Given:
<component_dir>(repo-root prefix, e.g.,artifact-core) and<base_ref>(e.g.,main). - Does: fetches
origin/<base_ref>, computesmerge-base(origin/<base_ref>, HEAD), and diffsMB..HEAD; then verifies every changed path starts with<component_dir>/. -
Outcome: exits
0if all changed files are under<component_dir>/; otherwise exits1and lists the offending paths. -
ensure_changed_files_outside_dirs.sh: - Given:
<base_ref>and one or more<dir>prefixes (repo-root, e.g.,docs,packages/app). - Does: fetches
origin/<base_ref>, computesmerge-base(origin/<base_ref>, HEAD), diffsMB..HEAD; then checks that no changed path starts with any forbidden<dir>/(regex-escaped, trailing slash normalized). - Outcome: exits
0if all changes are outside the listed directories; otherwise exits1and prints the paths that violate the rule.
GitHub API Scripts (.github/scripts/github/)¶
check_open_pr.sh:- Given:
<branch_name>(e.g.,feature-core/my-feature). - Does: queries the GitHub API via
gh pr listto check if there is an open pull request with the given branch as its head. - Outcome: prints
trueto stdout if an open PR exists,falseotherwise; exits0on success,1on missing arguments or ifGH_TOKENis not set. - Environment: requires
GH_TOKENenvironment variable for API access. - Usage: used by
CI_PUSH[CORE/EXPERIMENT/TORCH]workflows to skip CI when a PR is open (to avoid duplicate runs withCI_PR[DEV_*]).
Version Bump Scripts (.github/scripts/version_bump/)¶
get_bump_type.sh:- Given: the current commit context (typically the PR merge commit).
- Does: reads the commit description/body of the current commit, delegates to
lint_commit_description.shto validate and extract the semantic version prefix. - Outcome: prints the resolved bump type (
patch|minor|major|no-bump) to stdout; exits1if the description is empty or lacks a valid prefix. -
Usage:
get_bump_type.sh(reads HEAD commit description) -
get_component_name.sh: - Given: the current commit context (expected to be a GitHub merge commit).
- Does: verifies HEAD is a merge commit (
check_is_merge_commit.sh), parses the commit subject (e.g.,Merge pull request #123 from user/dev-core), extracts and validates the branch name vialint_commit_message.sh, and returns the component name. - Outcome: prints the component name (e.g.,
core) to stdout, or empty string if not a merge commit or no component found; exits0always (consumers check for empty stdout). -
Usage:
get_component_name.sh(reads HEAD commit message) -
get_pyproject_path.sh: - Given: a component name (e.g.,
core,experiment,torch, orroot). - Does: maps the component name to its artifact directory (e.g.,
core→artifact-core), resolves the expectedpyproject.tomllocation, and verifies the file exists. Forrootor no component, uses the repository rootpyproject.toml. -
Outcome: prints the repo-relative path to
pyproject.tomlto stdout (e.g.,artifact-core/pyproject.toml); exits1with an error if it cannot find the required file. -
update_pyproject.sh: - Given:
<pyproject_path>and<bump_type>(patch|minor|major). - Does: uses Poetry's
versioncommand to bump the version in the givenpyproject.toml. Poetry handles reading the current version, calculating the new version according to semantic versioning rules, and updating the file in place. - Outcome: prints the new version to stdout (e.g.,
1.3.0) and exits0; exits1if the file is missing, bump type is invalid, or Poetry encounters an error. -
Note: Requires Poetry to be installed and available in PATH.
-
get_component_tag.sh: - Given:
<version>and optional<component_name>(e.g.,1.3.0andcore). - Does: formats a tag string:
v<version>if no component, or<component>-v<version>(e.g.,core-v1.3.0). -
Outcome: prints the tag name to stdout; exits
1if inputs are empty or malformed. -
push_version_update.sh: - Given: the modified repo state,
<tag_name>, and commit message context. - Does: stages changes (e.g.,
pyproject.toml), creates a commit, creates/updates the Git tag, and pushes commit + tag to the remote (typicallyorigin). Can be gated by CI permissions on forks. -
Outcome: prints a short summary (commit and tag) to stderr/stdout and exits
0; exits1on any git error (e.g., auth, non-fast-forward, missing remote). -
bump_component_version.sh: - Given:
<bump_type>,<component_name>, and optionally an explicit<pyproject_path>. - Does: resolves the
pyproject.toml(viaget_pyproject_path.shif needed), uses Poetry to bump the version (update_pyproject.sh), computes the tag (get_component_tag.sh), and pushes the changes with the tag (push_version_update.sh). -
Outcome: prints the new version and tag to stdout (or logs), exits
0on success; exits1if any step fails (resolve, update, tag, or push). -
job.sh: - Given:
<component_name>and<bump_type>as CLI arguments. - Does: validates arguments, skips if
bump_typeisno-bumporcomponentisroot, derives/locates the component'spyproject.toml(get_pyproject_path.sh), and invokesbump_component_version.shto perform the version bump and push a version tag. - Outcome: performs an end-to-end automated version bump; outputs
component=<name>andversion=<version>to stdout for logging purposes (workflows do not parse these). Exits0on success,1on failure (missing arguments, invalid bump type, missing pyproject.toml). - Usage:
job.sh <component_name> <bump_type>(e.g.,job.sh core minor) - Note: The component and bump type are passed as explicit arguments (not extracted internally), enabling the workflow to control the flow with separate extraction jobs.
CICD Script Functional Tests¶
Implementation Pattern¶
Unit-tests for the CI/CD scripts are implemented using the Bats framework.
They reside in the .github/tests directory. Their organization mirrors that of .github/scripts.
Their implementation typically adheres to the following pattern:
- set up a fake environment with mocked dependencies,
- run the script under consideration,
- assert correctness,
- clean up the test environment.
Execution¶
To execute the tests, use the following command (from the monorepo root):
# Run all tests
bats -r .github/tests
# Run tests for a specific directory
bats -r .github/tests/linting
bats -r .github/tests/version_bump
# Run a specific test file
bats .github/tests/linting/test_lint_pr_title.bats
Relevant Pages¶
For a specification of the project's DevOps pipelines please consult the relevant docs.