Reusable CI/CD workflows for TetraScience repositories.
Examples below use @main for readability. Production callers should pin reusable workflow references to an immutable release tag or commit SHA.
Reusable workflow that runs actionlint to lint all GitHub Actions workflow files in the repo. Catches syntax errors, type mismatches, deprecated features, and security issues in workflow YAML.
jobs:
actionlint:
uses: tetrascience/ts-ci-cd-lib/.github/workflows/ghactionlint.yml@main| Input | Description | Required | Default |
|---|---|---|---|
actionlint_version |
actionlint version to install | No | "1.7.12" |
actionlint_sha256 |
SHA-256 of actionlint_<version>_linux_amd64.tar.gz (must match actionlint_version) |
No | pinned to default version |
zizmor_version |
zizmor version to install | No | "1.16.3" |
zizmor_sha256 |
SHA-256 of zizmor-x86_64-unknown-linux-gnu.tar.gz (must match zizmor_version) |
No | pinned to default version |
Both binaries are downloaded from GitHub release assets and verified against pinned SHA-256 hashes. To bump either tool, set the *_version input alongside the matching *_sha256.
Reusable workflow that runs knip to detect unused dependencies, exports, types, and files. Handles checkout, Node setup, Corepack, registry auth, install, and caching — callers just wire it up.
jobs:
knip:
uses: tetrascience/ts-ci-cd-lib/.github/workflows/knip.yml@main
secrets:
AUTH_TOKEN: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }}
REGISTRY: ${{ secrets.JFROG_ARTIFACTORY_NPM_VIRTUAL_URL }} knip:
uses: tetrascience/ts-ci-cd-lib/.github/workflows/knip.yml@main
with:
working_directory: packages/my-lib
args: "--include dependencies"
secrets:
AUTH_TOKEN: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }}
REGISTRY: ${{ secrets.JFROG_ARTIFACTORY_NPM_VIRTUAL_URL }}| Input | Description | Required | Default |
|---|---|---|---|
node_version |
Node.js version | No | "20" |
knip_version |
Knip version to run | No | "6.10.0" |
working_directory |
Directory to run knip in | No | "." |
args |
Additional arguments passed to knip | No | "" |
| Secret | Description | Required |
|---|---|---|
AUTH_TOKEN |
JFrog npm auth token (for installing private deps) | No |
REGISTRY |
JFrog virtual registry URL | No |
Same tool, no workflow needed — add directly to .husky/pre-commit:
npx --yes knip@6.10.0 --include dependenciesFor most repos, knip works out of the box. If you need to ignore specific patterns, create knip.json:
{
"ignore": ["src/generated/**"],
"ignoreDependencies": ["@types/*"]
}Reusable workflow for publishing npm packages to JFrog Artifactory or the public npm registry.
name: Publish
on:
push:
tags:
- "v*"
jobs:
publish:
uses: tetrascience/ts-ci-cd-lib/.github/workflows/publish-npm-package.yml@main
with:
node_version: "20"
secrets:
AUTH_TOKEN: ${{ secrets.JFROG_AUTH_TOKEN }}
REGISTRY: ${{ secrets.JFROG_NPM_REGISTRY }}
PUBLISH_REGISTRY: ${{ secrets.JFROG_NPM_PUBLISH_REGISTRY }}| Input | Description | Required | Default |
|---|---|---|---|
node_version |
Node.js version | No | "20" |
working_directory |
Directory containing the package to publish | No | "." |
prerelease_tag |
Prerelease tag for version suffix and npm dist-tag (e.g., alpha, beta). Leave empty for non-prerelease versions. | No | "" |
run_tests |
Whether to run tests before publishing | No | true |
publish_to_public_npm |
Set to true to confirm publishing to public npm registry | No | false |
pre_install_command |
Shell command to run after checkout and before registry auth + install. Runs at repo root (not working_directory). Use for codegen that produces the working_directory contents, and keep the value static in the caller workflow. |
No | "" |
| Secret | Description | Required |
|---|---|---|
AUTH_TOKEN |
Authentication token for JFrog Artifactory | No |
REGISTRY |
JFrog Artifactory npm registry URL (for installing dependencies) | No |
PUBLISH_REGISTRY |
Registry URL for publishing the package | Yes |
To publish to the public npm registry (https://registry.npmjs.org):
- Set
publish_to_public_npm: truein the workflow inputs - Configure npm trusted publishing for the package
- Set
PUBLISH_REGISTRYtohttps://registry.npmjs.org
The workflow uses OIDC trusted publishing for public npm publishes and automatically updates the package scope from @tetrascience to @tetrascience-npm when publishing to the public registry.
Reusable workflow for checking broken links in markdown files using lychee. The calling repository provides its own lychee.toml configuration file in the repo root.
name: Link Check
on:
push:
branches: [main]
paths: ["**/*.md", "lychee.toml", ".lycheeignore"]
pull_request:
paths: ["**/*.md", "lychee.toml", ".lycheeignore"]
schedule:
- cron: "0 8 * * 1"
workflow_dispatch:
permissions:
contents: read
jobs:
check-links:
uses: tetrascience/ts-ci-cd-lib/.github/workflows/check-links.yml@main| Input | Description | Required | Default |
|---|---|---|---|
lychee_args |
Arguments passed to lychee. The calling repo's lychee.toml handles most configuration. |
No | "--cache --max-cache-age 1d ." |
No secrets required. The workflow uses the automatically available GITHUB_TOKEN for authenticating with the GitHub API (to avoid rate limits when checking GitHub links).
Create a lychee.toml file in your repository root:
max_concurrency = 4
max_retries = 3
timeout = 20
accept = [200, 204, 301, 429]
exclude = [
"^http://localhost",
"^http://127\\.0\\.0\\.1",
"^https?://example\\.com",
]Deploys a service to a predev environment, runs E2E tests via CodeBuild, and streams results back to the PR.
name: E2E Tests
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
push:
branches: [development]
permissions:
id-token: write
contents: read
jobs:
e2e:
uses: tetrascience/ts-ci-cd-lib/.github/workflows/e2e-codebuild.yml@main
with:
environment: predev5
deploy_paths: 'src/** migrations/** package.json yarn.lock Dockerfile'
buildspec: buildspec.e2e.yml
secrets:
JFROG_ARTIFACTORY_NPM_VIRTUAL_URL: ${{ secrets.JFROG_ARTIFACTORY_NPM_VIRTUAL_URL }}
JFROG_ARTIFACTORY_READ_NPM_AUTH: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }}
GITHUB_PAT: ${{ secrets.ARTIFACT_BUILD_GITHUB_TS_DEVOPS_PAT }}Non-secret test configuration (org slugs, app IDs, subdomain bases, etc.) should live in the service repo as a static config file (e.g. test/e2e/environments.ts) keyed by environment name. The workflow passes E2E_ENVIRONMENT so tests can select the right config at runtime. Only actual secrets (auth tokens) belong in SSM.
Each service provides its own buildspec.e2e.yml. The workflow passes the following as CodeBuild environment variables:
| Variable | Description |
|---|---|
E2E_ENVIRONMENT |
Target environment name (e.g. predev3, dev) |
JFROG_ARTIFACTORY_URL |
JFrog npm registry URL |
JFROG_ARTIFACTORY_AUTH |
JFrog npm credentials |
Authentication is handled by each service's test setup (e.g. a globalSetup that reads the TDP admin password from SSM and logs in). The CodeBuild IAM role has access to read SSM parameters under /tetrascience/{environment}/platform/ADMIN_PASSWORD.
Example buildspec:
version: 0.2
phases:
install:
runtime-versions:
nodejs: 20
commands:
- npm install -g corepack
- corepack enable
- YARN_VERSION=$(node -p "require('./package.json').packageManager.split('@')[1]")
- corepack prepare "yarn@$YARN_VERSION" --activate
- |
if [ -n "$JFROG_ARTIFACTORY_URL" ]; then
yarn config set npmRegistryServer "$JFROG_ARTIFACTORY_URL"
yarn config set npmAuthIdent "$JFROG_ARTIFACTORY_AUTH"
yarn config set npmAlwaysAuth true
fi
- yarn install --immutable
build:
commands:
- yarn test:e2e| Input | Required | Default | Description |
|---|---|---|---|
environment |
Yes | Target environment. One of predev, predev2…predev8, dev, preuat, uat. |
|
deploy_paths |
Yes | Globs that trigger deploy. Empty = never deploy (observe-only, see below). | |
buildspec |
Yes | Path to buildspec in the caller repo | |
deploy_workflow |
No | ci.yml |
Workflow waited on after pushing to env branch |
image_override |
No | Override CodeBuild image | |
compute_type_override |
No | Override CodeBuild compute (e.g. BUILD_GENERAL1_MEDIUM) |
|
stream_codebuild_logs |
No | false |
Stream raw CodeBuild logs into the GitHub Actions log |
timeout_minutes |
No | 25 |
Max minutes for the E2E job |
passthrough_env |
No | Extra non-secret env vars for the run, one NAME=VALUE per line. See below. |
|
gh_environment |
No | GitHub Environment to source per-env config from. The e2e job runs in it and forwards every E2E_* variable into the run. See below. |
| Secret | Required | Description |
|---|---|---|
JFROG_ARTIFACTORY_NPM_VIRTUAL_URL |
Yes | JFrog npm registry URL |
JFROG_ARTIFACTORY_READ_NPM_AUTH |
Yes | JFrog npm credentials |
GITHUB_PAT |
Yes | PAT for cross-repo access + deploy push |
ZEPHYR_CYCLE_KEY |
No | Cycle to record into |
ZEPHYR_API_TOKEN |
No | Zephyr Scale API token |
ZEPHYR_ACCOUNT_ID |
No | Jira account id for executedById |
E2E_USER_PASSWORD |
No | Password for a dedicated e2e login user. Masked in the Actions log; reaches CodeBuild as a plaintext env override (see the note below). |
The Zephyr secrets and E2E_USER_PASSWORD are forwarded as CodeBuild environment variables
only when non-empty, so omitting them leaves the buildspec's own lookups in charge. Pass
them when the caller already holds these values as GitHub secrets.
Note on E2E_USER_PASSWORD: it is masked in the GitHub Actions log, but it is forwarded as a
plaintext CodeBuild environment override, so it appears in cleartext in the target account's
CloudTrail StartBuild request and on the build's environment in the CodeBuild console. Use a
dedicated low-privilege e2e user, not a shared credential.
The suite runs inside CodeBuild, so GitHub Environment values do not reach the test process on their own; only what this workflow forwards does. There are two ways to forward config, and they compose.
gh_environment (recommended for per-environment config). Set it to a GitHub Environment
name. The e2e job then runs in that Environment and forwards every non-empty E2E_* variable
visible to the job (org, repo, and the selected Environment, with the Environment winning) into
the run, with no per-variable wiring here. Add a new E2E_* var and it flows through
automatically. When gh_environment is unset nothing is forwarded, so callers that do not opt in
are unchanged. E2E_USER_PASSWORD is never forwarded as a variable even if set as one; it travels
only through the masked secret input. A uses: caller cannot read Environment vars itself, which
is why this reads them inside the job.
with:
environment: uat
deploy_paths: ''
buildspec: buildspec.e2e.yml
gh_environment: uat # forwards every E2E_* var visible to the job
secrets:
# a repo or org secret; Environment secrets do not reach a reusable workflow
E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}passthrough_env (for literal or repo-level values). One NAME=VALUE per line, blanks
ignored, everything after the first = is the value. Use it for values the caller holds directly
(literals or repo-level vars), not Environment vars:
with:
passthrough_env: |
E2E_WEBAPP_ORIGIN=https://example.test
E2E_ORG_SLUG=${{ vars.E2E_ORG_SLUG }} # a repo-level var, not an Environment varPrecedence when a name comes from more than one source (last wins): passthrough_env, then
Environment E2E_* vars, then the reserved vars this workflow controls (E2E_ENVIRONMENT,
JFROG_ARTIFACTORY_*). So the Environment overrides passthrough, and the reserved vars override
both. Never put secrets in passthrough_env: with: inputs are not masked in logs. Secrets
go through declared secrets: inputs, which is why the password is separate. Unset values
interpolate to empty and are dropped; have the suite treat blank as unset.
Buildspec authors: a buildspec that assigns these unconditionally will clobber what the workflow passes. Prefer the inbound value:
export ZEPHYR_CYCLE_KEY="${ZEPHYR_CYCLE_KEY:-$(aws ssm get-parameter ... || echo "")}"
- check-changes — compares changed files against
deploy_paths. Skipped ifdeploy_pathsis empty. - deploy — pushes to the env branch, waits for the deploy workflow to complete. Skipped if no service code changed.
- e2e — uploads source to S3, triggers CodeBuild, writes job summary, and links CloudWatch logs. Raw log streaming is opt-in with
stream_codebuild_logs.
Passing deploy_paths: '' skips check-changes and deploy, leaving only the
CodeBuild run. Use it against environments this pipeline does not deploy, so the suite
verifies what is already there rather than what a PR would ship. Those environments
reject a non-empty deploy_paths outright.
Pair it with a workflow_dispatch trigger in the caller. The caller workflow must exist
on whichever branch you dispatch from.
on:
workflow_dispatch:
jobs:
e2e:
uses: tetrascience/ts-ci-cd-lib/.github/workflows/e2e-codebuild.yml@main
with:
environment: uat
deploy_paths: ''
buildspec: buildspec.e2e.yml
secrets: ...GITHUB_PAT is still declared required even though nothing uses it once the deploy job
is skipped.
The shared CodeBuild project (tdp-e2e) must exist in the target account before an
environment can be used. It comes from
ts-cloudformation-service/infrastructure/tdp-e2e.yaml,
deployed either as a substack of the TDP service stack via EnableE2E=true or as a
standalone stack. One project per account serves every repo: source and buildspec are
per-call overrides, and uploads are namespaced by repo name.
The workflow derives the role and bucket names by convention, so an environment's
CF_ENVIRONMENTS entry must match that stack's EnvironmentName.
Composite actions are referenced as a step (uses:) inside your own job, unlike the reusable workflows above (which are referenced at the job level).
Installs a single npm package that is published only to a private JFrog Artifactory registry, as a leaf tarball extracted into an already-installed node_modules.
Use this for packages that cannot be added to package.json / yarn.lock — for example in a repo pinned to the public npm registry, where adding a private dependency would break external contributors' yarn install. The action fetches just the one package via npm pack and extracts it in place; it deliberately does not use npm install, which reconciles the whole dependency tree and corrupts a Yarn-managed node_modules (ENOTEMPTY … rmdir node_modules/<pkg>/dist).
Run it after yarn install (it extracts into the existing node_modules):
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: "yarn"
- run: corepack enable
- run: yarn install --immutable
- name: Install ts-lib-zephyr-nodejs (JFrog)
uses: tetrascience/ts-ci-cd-lib/install-jfrog-npm-package@main
with:
package: ts-lib-zephyr-nodejs
version: "0.4.0"
# The virtual registry URL is infra info, not a credential — hardcode it
# (or pass a non-environment-scoped secret).
registry-url: https://<org>.jfrog.io/artifactory/api/npm/<repo>/
auth: ${{ secrets.JFROG_ARTIFACTORY_READ_NPM_AUTH }}| Input | Description | Required | Default |
|---|---|---|---|
package |
npm package name to install (e.g. ts-lib-zephyr-nodejs). Scoped names are supported. |
Yes | — |
version |
Exact version to install (e.g. 0.4.0). |
Yes | — |
registry-url |
JFrog virtual (read) registry URL, e.g. https://<org>.jfrog.io/artifactory/api/npm/<repo>/. |
Yes | — |
auth |
Read-only Artifactory credential; interpreted per auth-type. Pass a secret. |
Yes | — |
auth-type |
npm auth field: _auth (base64 username:password) or _authToken (bearer token). |
No | _auth |
auth-type: most TetraScience
JFROG_ARTIFACTORY_*_NPM_AUTHsecrets are a base64username:passwordidentity (npm_auth, equivalent to Yarn'snpmAuthIdent) — the default. Setauth-type: _authTokenonly if your credential is a bearer token.