Skip to content
Open
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
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,106 @@ steps:
# - Repository information
```

### CircleCI Integration

In CircleCI, PyLynk automatically detects and extracts workflow information:

#### Using Docker Image (Recommended)
```yaml
version: 2.1

jobs:
upload-sbom:
docker:
- image: cimg/base:stable
steps:
- checkout
- setup_remote_docker

- run:
name: Generate SBOM
command: |
# Your SBOM generation command here
# Example: syft . -o json=sbom.json

- run:
name: Upload SBOM to Interlynk
command: |
docker run --rm \
-e INTERLYNK_SECURITY_TOKEN=${INTERLYNK_TOKEN} \
-e CIRCLECI=${CIRCLECI} \
-e CIRCLE_TAG=${CIRCLE_TAG} \
-e CIRCLE_PULL_REQUEST=${CIRCLE_PULL_REQUEST} \
-e CIRCLE_BRANCH=${CIRCLE_BRANCH} \
-e CIRCLE_USERNAME=${CIRCLE_USERNAME} \
-e CIRCLE_BUILD_NUM=${CIRCLE_BUILD_NUM} \
-e CIRCLE_SHA1=${CIRCLE_SHA1} \
-e CIRCLE_BUILD_URL=${CIRCLE_BUILD_URL} \
-e CIRCLE_WORKFLOW_ID=${CIRCLE_WORKFLOW_ID} \
-e CIRCLE_JOB=${CIRCLE_JOB} \
-e CIRCLE_PROJECT_REPONAME=${CIRCLE_PROJECT_REPONAME} \
-e CIRCLE_PROJECT_USERNAME=${CIRCLE_PROJECT_USERNAME} \
-e CIRCLE_REPOSITORY_URL=${CIRCLE_REPOSITORY_URL} \
-v $(pwd):/app/data \
ghcr.io/interlynk-io/pylynk \
upload --prod 'my-product' --sbom /app/data/sbom.json
# PyLynk automatically captures:
# - Event type (pull_request, push, or release)
# - Release tag (for tag-triggered builds via CIRCLE_TAG)
# - PR number and URL (extracted from CIRCLE_PULL_REQUEST)
# - Source branch (CIRCLE_BRANCH)
# - Author (CIRCLE_USERNAME)
# - Build number and URL
# - Workflow ID and job name
# - Repository information

workflows:
version: 2
build_and_upload:
jobs:
- upload-sbom:
filters:
tags:
only: /^v.*/
```

#### Using Python Directly
```yaml
version: 2.1

jobs:
upload-sbom:
docker:
- image: cimg/python:3.9
steps:
- checkout

- run:
name: Install dependencies
command: pip install -r requirements.txt

- run:
name: Generate SBOM
command: |
# Your SBOM generation command here

- run:
name: Upload SBOM to Interlynk
command: |
python3 pylynk.py upload --prod 'my-product' --sbom sbom.json
environment:
INTERLYNK_SECURITY_TOKEN: ${INTERLYNK_TOKEN}

workflows:
version: 2
build_and_upload:
jobs:
- upload-sbom:
filters:
tags:
only: /^v.*/
```

### Generic CI Support

PyLynk also supports generic CI environments by checking common environment variables. When `CI=true` is set, PyLynk will attempt to extract build and PR information from standard environment variables:
Expand Down
56 changes: 55 additions & 1 deletion pylynk/utils/ci_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
logger = logging.getLogger(__name__)

class CIInfo:
"""Extract CI/CD environment information for GitHub Actions, Bitbucket Pipelines, and Azure DevOps."""
"""Extract CI/CD environment information for GitHub Actions, Bitbucket Pipelines, Azure DevOps, and CircleCI."""

def __init__(self):
self.ci_provider = self._detect_ci_provider()
Expand All @@ -28,6 +28,8 @@ def _detect_ci_provider(self):
return 'bitbucket_pipelines'
if os.getenv('TF_BUILD', '').lower() in ['true', '1'] or os.getenv('SYSTEM_TEAMFOUNDATIONCOLLECTIONURI'):
return 'azure_devops'
if os.getenv('CIRCLECI') == 'true':
return 'circleci'
return None

def _extract_event_info(self):
Expand Down Expand Up @@ -184,6 +186,43 @@ def _extract_event_info(self):
else:
event_info['event_type'] = "unknown"

elif self.ci_provider == 'circleci':
# Event type detection for CircleCI
# Check for tag first (highest priority)
if os.getenv('CIRCLE_TAG'):
event_info['event_type'] = 'release'
event_info.update({
'release_tag': os.getenv('CIRCLE_TAG'),
'author': os.getenv('CIRCLE_USERNAME')
})
# Check if this is a PR event
elif os.getenv('CIRCLE_PULL_REQUEST'):
event_info['event_type'] = 'pull_request'
pr_url = os.getenv('CIRCLE_PULL_REQUEST')
# Extract PR number from URL (format: https://github.com/owner/repo/pull/123)
pr_number = None
if pr_url:
parts = pr_url.rstrip('/').split('/')
if len(parts) > 0 and parts[-2] == 'pull':
pr_number = parts[-1]

event_info.update({
'number': pr_number,
'url': pr_url,
'source_branch': os.getenv('CIRCLE_BRANCH'),
# CircleCI doesn't provide target branch in env vars
'author': os.getenv('CIRCLE_USERNAME')
})
# Otherwise it's a push
elif os.getenv('CIRCLE_BRANCH'):
event_info['event_type'] = 'push'
event_info.update({
'source_branch': os.getenv('CIRCLE_BRANCH'),
'author': os.getenv('CIRCLE_USERNAME')
})
else:
event_info['event_type'] = 'unknown'

else:
# Generic CI fallback - support common CI environment variables
# Detect event type
Expand Down Expand Up @@ -261,6 +300,15 @@ def _extract_build_info(self):
'commit_sha': os.getenv('BITBUCKET_COMMIT'),
'build_url': f"https://bitbucket.org/{os.getenv('BITBUCKET_WORKSPACE')}/{os.getenv('BITBUCKET_REPO_SLUG')}/pipelines/results/{os.getenv('BITBUCKET_BUILD_NUMBER')}"
})
elif self.ci_provider == 'circleci':
build_info.update({
'build_id': os.getenv('CIRCLE_BUILD_NUM'),
'build_number': os.getenv('CIRCLE_BUILD_NUM'),
'commit_sha': os.getenv('CIRCLE_SHA1'),
'build_url': os.getenv('CIRCLE_BUILD_URL'),
'workflow_id': os.getenv('CIRCLE_WORKFLOW_ID'),
'job_name': os.getenv('CIRCLE_JOB')
})
# Fallbacks - check multiple common environment variable names
build_info.setdefault('commit_sha',
os.getenv('GIT_COMMIT') or
Expand Down Expand Up @@ -298,6 +346,12 @@ def _extract_repository_info(self):
'owner': os.getenv('BITBUCKET_WORKSPACE'),
'url': f"https://bitbucket.org/{os.getenv('BITBUCKET_WORKSPACE')}/{os.getenv('BITBUCKET_REPO_SLUG')}"
})
elif self.ci_provider == 'circleci':
repo_info.update({
'name': os.getenv('CIRCLE_PROJECT_REPONAME'),
'owner': os.getenv('CIRCLE_PROJECT_USERNAME'),
'url': os.getenv('CIRCLE_REPOSITORY_URL')
})
# Fallbacks - check common repository environment variables
repo_info.setdefault('url', os.getenv('REPO_URL') or os.getenv('REPOSITORY_URL'))
repo_info.setdefault('name', os.getenv('REPO_NAME') or os.getenv('REPOSITORY_NAME'))
Expand Down