GitLab Hardening Guide
DevOps platform security for CI/CD pipelines, repository access, and runners
Overview
GitLab is used by 50%+ of Fortune 100 with 30,000+ paying customers. Integrated CI/CD pipelines, container registry, and secrets management concentrate attack surface. Runner tokens, project API keys, and OAuth integrations with cloud providers enable code injection and infrastructure access. A compromised GitLab instance provides attackers with source code, CI/CD secrets, and deployment capabilities.
Intended Audience
- Security engineers hardening GitLab instances
- DevOps engineers configuring CI/CD security
- GRC professionals assessing DevSecOps compliance
- Platform teams managing GitLab infrastructure
How to Use This Guide
- L1 (Baseline): Essential controls for all organizations
- L2 (Hardened): Enhanced controls for security-sensitive environments
- L3 (Maximum Security): Strictest controls for regulated industries
Scope
This guide covers GitLab security configurations including authentication, CI/CD pipeline security, runner hardening, and third-party integration controls.
Table of Contents
- Authentication & Access Controls
- CI/CD Pipeline Security
- Runner Security
- Repository Security
- Secrets Management
- Monitoring & Detection
- Compliance Quick Reference
1. Authentication & Access Controls
1.1 Enforce SSO with MFA
Profile Level: L1 (Baseline) CIS Controls: 6.3, 6.5 NIST 800-53: IA-2(1)
Description
Require SAML/OIDC SSO with MFA for all GitLab authentication, eliminating password-based access.
Rationale
Why This Matters:
- GitLab credentials provide access to source code and CI/CD pipelines
- Compromised accounts can inject malicious code
- SSO enables centralized access control and MFA enforcement
Attack Scenario: Malicious .gitlab-ci.yml injects backdoor during build; stolen runner token enables unauthorized deployments.
ClickOps Implementation (GitLab.com Premium/Ultimate)
Step 1: Configure SAML SSO
- Navigate to: Group → Settings → SAML SSO
- Configure:
- Identity provider SSO URL: Your IdP endpoint
- Certificate fingerprint: From IdP
- Enforce SSO: Enable
- Click Save changes
Step 2: Enforce Group-Managed Accounts
- Navigate to: Group → Settings → SAML SSO
- Enable: Enforce SSO-only authentication for web activity
- Enable: Enforce SSO-only authentication for Git and Dependency Proxy activity
Step 3: Disable Password Authentication
- Navigate to: Admin → Settings → General → Sign-in restrictions
- Disable: Password authentication enabled for web interface
- Disable: Password authentication enabled for Git over HTTP(S)
Code Implementation
Code Pack: CLI Script
# /etc/gitlab/gitlab.rb
# SAML Configuration
gitlab_rails['omniauth_enabled'] = true
gitlab_rails['omniauth_allow_single_sign_on'] = ['saml']
gitlab_rails['omniauth_block_auto_created_users'] = false
gitlab_rails['omniauth_providers'] = [
{
name: 'saml',
args: {
assertion_consumer_service_url: 'https://gitlab.company.com/users/auth/saml/callback',
idp_cert_fingerprint: 'XX:XX:XX...',
idp_sso_target_url: 'https://idp.company.com/saml/sso',
issuer: 'https://gitlab.company.com',
name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
}
}
]
# Disable password authentication
gitlab_rails['gitlab_signin_enabled'] = false
Compliance Mappings
| Framework | Control ID | Control Description |
|---|---|---|
| SOC 2 | CC6.1 | Logical access controls |
| NIST 800-53 | IA-2(1) | MFA for network access |
1.2 Implement Granular Project Permissions
Profile Level: L1 (Baseline) NIST 800-53: AC-3, AC-6
Description
Configure project-level access controls using GitLab’s role-based permissions.
ClickOps Implementation
Step 1: Define Role Strategy
| Role | Permissions | Use Case |
|---|---|---|
| Guest | View issues, wiki | External stakeholders |
| Reporter | Clone, view CI/CD | QA, read-only developers |
| Developer | Push to non-protected branches | Development team |
| Maintainer | Merge to protected, manage CI/CD | Tech leads |
| Owner | Full control | Project owners only |
Step 2: Configure Protected Branches
- Navigate to: Project → Settings → Repository → Protected branches
- Protect
mainandrelease/*:- Allowed to merge: Maintainers
- Allowed to push: No one (force MR workflow)
- Require approval from code owners: Enable
Step 3: Enable Required Approvals
- Navigate to: Project → Settings → Merge requests
- Configure:
- Approvals required: 2 (minimum)
- Prevent approval by author: Enable
- Prevent editing approval rules: Enable
1.3 Configure Personal Access Token Policies
Profile Level: L1 (Baseline) NIST 800-53: IA-5
Description
Restrict personal access token (PAT) creation and enforce expiration policies.
ClickOps Implementation
Step 1: Set Token Expiration Limits
- Navigate to: Admin → Settings → General → Account and limit
- Configure:
- Maximum allowable lifetime for access tokens: 90 days
- Limit project access token creation: Enable
Step 2: Disable API Scope for Non-Essential Tokens
- Audit tokens with
apiscope - Replace with minimal scopes (read_repository, write_repository)
Code Pack: API Script
# List all active personal access tokens and flag risky configurations
info "1.3 Retrieving active personal access tokens..."
PAGE=1
ALL_PATS="[]"
while true; do
RESPONSE=$(gl_get "/personal_access_tokens?state=active&per_page=100&page=${PAGE}" 2>/dev/null) || break
COUNT=$(echo "${RESPONSE}" | jq 'length' 2>/dev/null || echo "0")
[ "${COUNT}" -eq 0 ] && break
ALL_PATS=$(echo "${ALL_PATS} ${RESPONSE}" | jq -s 'add')
PAGE=$((PAGE + 1))
done
TOTAL=$(echo "${ALL_PATS}" | jq 'length' 2>/dev/null || echo "0")
info "1.3 Found ${TOTAL} active personal access token(s)"
# Flag tokens with overly broad 'api' scope
API_SCOPE_PATS=$(echo "${ALL_PATS}" | jq '[.[] | select(.scopes | index("api"))]' 2>/dev/null || echo "[]")
API_SCOPE_COUNT=$(echo "${API_SCOPE_PATS}" | jq 'length' 2>/dev/null || echo "0")
if [ "${API_SCOPE_COUNT}" -gt 0 ]; then
warn "1.3 Found ${API_SCOPE_COUNT} token(s) with full 'api' scope (overly permissive)"
echo "${API_SCOPE_PATS}" | jq -r '.[] | " - \(.name // "unnamed") (user: \(.user_id // "unknown"), created: \(.created_at // "unknown"))"' 2>/dev/null || true
fi
# Flag tokens with no expiration date
NO_EXPIRY_PATS=$(echo "${ALL_PATS}" | jq '[.[] | select(.expires_at == null)]' 2>/dev/null || echo "[]")
NO_EXPIRY_COUNT=$(echo "${NO_EXPIRY_PATS}" | jq 'length' 2>/dev/null || echo "0")
if [ "${NO_EXPIRY_COUNT}" -gt 0 ]; then
warn "1.3 Found ${NO_EXPIRY_COUNT} token(s) with no expiration date"
echo "${NO_EXPIRY_PATS}" | jq -r '.[] | " - \(.name // "unnamed") (user: \(.user_id // "unknown"), scopes: \(.scopes | join(", ")))"' 2>/dev/null || true
fi
# Flag tokens with write_repository scope (supply chain risk)
WRITE_REPO_PATS=$(echo "${ALL_PATS}" | jq '[.[] | select(.scopes | index("write_repository"))]' 2>/dev/null || echo "[]")
WRITE_REPO_COUNT=$(echo "${WRITE_REPO_PATS}" | jq 'length' 2>/dev/null || echo "0")
if [ "${WRITE_REPO_COUNT}" -gt 0 ]; then
warn "1.3 Found ${WRITE_REPO_COUNT} token(s) with 'write_repository' scope"
echo "${WRITE_REPO_PATS}" | jq -r '.[] | " - \(.name // "unnamed") (user: \(.user_id // "unknown"), expires: \(.expires_at // "never"))"' 2>/dev/null || true
fi
Code Pack: Sigma Detection Rule
detection:
selection:
entity_type: 'PersonalAccessToken'
action: 'create'
condition: selection
fields:
- author_name
- entity_path
- target_details
- ip_address
- created_at
2. CI/CD Pipeline Security
2.1 Protect CI/CD Variables
Profile Level: L1 (Baseline) NIST 800-53: SC-28
Description
Configure CI/CD variables with appropriate protection levels and masking.
ClickOps Implementation
Step 1: Configure Variable Protection
- Navigate to: Project → Settings → CI/CD → Variables
- For each sensitive variable:
- Protect variable: Enable (only available in protected branches)
- Mask variable: Enable (hidden in job logs)
- Expand variable reference: Disable
Step 2: Use Group-Level Variables
- Navigate to: Group → Settings → CI/CD → Variables
- Define shared secrets at group level
- Limit duplication across projects
Step 3: Environment-Scoped Variables
- Create separate variables for each environment:
PROD_API_KEY(protected)STAGING_API_KEY
- Scope to specific environments
Code Implementation
Code Pack: API Script
# Retrieve all project-level CI/CD variables and check protection settings
VARIABLES=$(gl_get "/projects/${PROJECT_ID}/variables" 2>/dev/null) || {
fail "2.1 Failed to retrieve CI/CD variables -- check PROJECT_ID and token permissions"
increment_failed
summary
exit 0
}
VAR_COUNT=$(echo "${VARIABLES}" | jq 'length' 2>/dev/null || echo "0")
info "2.1 Found ${VAR_COUNT} CI/CD variable(s)"
UNPROTECTED=0
UNMASKED=0
RAW_EXPOSED=0
echo "${VARIABLES}" | jq -c '.[]' 2>/dev/null | while IFS= read -r var; do
KEY=$(echo "${var}" | jq -r '.key')
PROTECTED=$(echo "${var}" | jq -r '.protected')
MASKED=$(echo "${var}" | jq -r '.masked')
RAW=$(echo "${var}" | jq -r '.raw // false')
ISSUES=""
if [ "${PROTECTED}" != "true" ]; then
ISSUES="${ISSUES} unprotected"
fi
if [ "${MASKED}" != "true" ]; then
ISSUES="${ISSUES} unmasked"
fi
if [ "${RAW}" == "true" ]; then
ISSUES="${ISSUES} raw-exposed"
fi
if [ -n "${ISSUES}" ]; then
warn "2.1 Variable '${KEY}':${ISSUES}"
else
pass "2.1 Variable '${KEY}': protected + masked"
fi
done
# Summary counts (re-parse for totals since while-loop runs in subshell)
UNPROTECTED=$(echo "${VARIABLES}" | jq '[.[] | select(.protected != true)] | length' 2>/dev/null || echo "0")
UNMASKED=$(echo "${VARIABLES}" | jq '[.[] | select(.masked != true)] | length' 2>/dev/null || echo "0")
RAW_EXPOSED=$(echo "${VARIABLES}" | jq '[.[] | select(.raw == true)] | length' 2>/dev/null || echo "0")
info "2.1 Unprotected: ${UNPROTECTED}, Unmasked: ${UNMASKED}, Raw-exposed: ${RAW_EXPOSED}"
Code Pack: CLI Script
# .gitlab-ci.yml - Secure variable usage
variables:
# Never hardcode secrets
# Reference protected CI/CD variables
deploy_production:
stage: deploy
script:
- echo "Deploying with protected credentials"
- ./deploy.sh # Uses $PROD_API_KEY from CI/CD settings
environment:
name: production
rules:
- if: $CI_COMMIT_BRANCH == "main"
# Only run on protected branch with protected variables
2.2 Implement Pipeline Security Controls
Profile Level: L1 (Baseline) NIST 800-53: CM-7, SI-7
Description
Restrict pipeline execution and prevent unauthorized CI/CD modifications.
ClickOps Implementation
Step 1: Require Pipeline Approval for Forks
- Navigate to: Project → Settings → CI/CD → General pipelines
- Enable: Protect CI/CD variables in pipeline subscriptions
- Enable: CI/CD job token scope: Limit access to necessary projects
Step 2: Configure Merge Request Pipelines
- Navigate to: Project → Settings → Merge requests
- Enable: Pipelines must succeed before merge
- Enable: All discussions must be resolved
Step 3: Limit Who Can Run Pipelines
- Navigate to: Project → Settings → CI/CD
- Configure: Who can run pipelines on protected branches
- Restrict manual job triggers
2.3 Harden .gitlab-ci.yml Configuration
Profile Level: L2 (Hardened) NIST 800-53: CM-7
Description
Implement secure CI/CD configuration practices. See the CLI Code Pack below for a security-hardened .gitlab-ci.yml example.
Code Pack: CLI Script
# .gitlab-ci.yml - Security hardened example
default:
# Use specific image tags, not :latest
image: ruby:3.2.0-alpine@sha256:abc123...
# Limit job timeout
timeout: 30 minutes
# Run in isolated environment
tags:
- docker
- isolated
# Prevent secret leakage in logs
variables:
GIT_STRATEGY: clone
SECURE_LOG_LEVEL: "warn"
# Security scanning stages
stages:
- test
- security
- build
- deploy
sast:
stage: security
allow_failure: false # Block on security issues
dependency_scanning:
stage: security
allow_failure: false
container_scanning:
stage: security
allow_failure: false
# Restrict production deployment
deploy_production:
stage: deploy
script:
- ./deploy.sh
environment:
name: production
url: https://prod.company.com
rules:
# Only from main branch
- if: $CI_COMMIT_BRANCH == "main"
when: manual # Require manual approval
# Prevent concurrent deployments
resource_group: production
3. Runner Security
3.1 Isolate CI/CD Runners
Profile Level: L1 (Baseline) NIST 800-53: SC-7
Description
Deploy isolated runners for different trust levels and environments.
Implementation
Step 1: Create Runner Tiers
- shared-runners – general use, Docker executor, ephemeral containers
- group-runners – team-specific, isolated per business unit
- project-runners – sensitive projects, dedicated to single project
- production-runners – deployment only, network access to production, limited users
Code Pack: CLI Script
# Register runner with specific tags
gitlab-runner register \
--url "https://gitlab.company.com" \
--registration-token "${RUNNER_TOKEN}" \
--executor "docker" \
--docker-image "alpine:3.18" \
--tag-list "isolated,security-sensitive" \
--run-untagged="false" \
--locked="true"
[[runners]]
name = "secure-runner"
executor = "docker"
[runners.docker]
image = "alpine:3.18"
privileged = false # Never enable unless absolutely required
disable_entrypoint_overwrite = true
volumes = ["/cache"]
# Limit network access
network_mode = "bridge"
# Read-only root filesystem
read_only = true
# Drop capabilities
cap_drop = ["ALL"]
3.2 Rotate Runner Tokens
Profile Level: L1 (Baseline) NIST 800-53: IA-5(1)
Description
Implement regular runner token rotation to limit exposure from compromised tokens.
ClickOps Implementation
Step 1: Reset Runner Token
- Navigate to: Admin → CI/CD → Runners → [Runner]
- Click Reset registration token
- Update runner configuration with new token
Code Pack: CLI Script
# Reset project runner token
curl -X POST -H "PRIVATE-TOKEN: ${ADMIN_TOKEN}" \
"https://gitlab.company.com/api/v4/projects/${PROJECT_ID}/runners/reset_registration_token"
# Re-register runner
gitlab-runner unregister --all-runners
gitlab-runner register --non-interactive \
--url "https://gitlab.company.com" \
--registration-token "${NEW_TOKEN}" \
--executor "docker"
4. Repository Security
4.1 Enable Push Rules
Profile Level: L1 (Baseline) NIST 800-53: CM-3
Description
Configure push rules to prevent accidental secret commits and enforce commit hygiene.
ClickOps Implementation
Step 1: Configure Project Push Rules
- Navigate to: Project → Settings → Repository → Push rules
- Enable:
- Prevent pushing secret files: Enable
- Reject unsigned commits: Enable (L2)
- Check author email against verified: Enable
Step 2: Configure Secret Detection
See the CLI Code Pack below for the .gitlab-ci.yml secret detection configuration.
Code Pack: API Script
# Configure push rules: L1 enables prevent_secrets and deny_delete_tag;
# L2 additionally enables reject_unsigned_commits for commit signing enforcement.
info "4.1 Configuring push rules..."
PAYLOAD='{
"prevent_secrets": true,
"deny_delete_tag": true'
# L2: Add reject_unsigned_commits
if should_apply 2 2>/dev/null; then
info "4.1 L2: Enabling reject_unsigned_commits (commit signing required)"
PAYLOAD="${PAYLOAD}"',"reject_unsigned_commits": true'
fi
PAYLOAD="${PAYLOAD}"'}'
if [ -n "${EXISTING}" ] && [ "${EXISTING}" != "null" ]; then
# Update existing push rules
RESULT=$(gl_put "/projects/${PROJECT_ID}/push_rule" "${PAYLOAD}" 2>/dev/null) || {
fail "4.1 Failed to update push rules"
increment_failed
summary
exit 0
}
else
# Create new push rules
RESULT=$(gl_post "/projects/${PROJECT_ID}/push_rule" "${PAYLOAD}" 2>/dev/null) || {
fail "4.1 Failed to create push rules"
increment_failed
summary
exit 0
}
fi
Code Pack: CLI Script
# .gitlab-ci.yml
secret_detection:
stage: security
variables:
SECRET_DETECTION_HISTORIC_SCAN: "true"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Code Pack: Sigma Detection Rule
detection:
selection:
entity_type: 'PushRule'
action:
- 'create'
- 'update'
- 'destroy'
condition: selection
fields:
- author_name
- entity_path
- target_details
- ip_address
- created_at
4.2 Enable Commit Signing
Profile Level: L2 (Hardened) NIST 800-53: AU-10
Description
Require GPG or SSH signed commits to verify commit authorship.
ClickOps Implementation
Step 1: Configure Signature Requirements
- Navigate to: Project → Settings → Repository → Push rules
- Enable: Reject unsigned commits
- Enable: Reject unverified users
Step 2: User Setup
- Navigate to: User Settings → GPG Keys
- Add GPG public key
- Configure git client (see CLI Code Pack below)
Code Pack: CLI Script
git config --global commit.gpgsign true
git config --global user.signingkey YOUR_KEY_ID
5. Secrets Management
5.1 Use External Secrets Management
Profile Level: L2 (Hardened) NIST 800-53: SC-28
Description
Integrate with external secrets managers instead of storing secrets in GitLab.
HashiCorp Vault Integration
Code Pack: CLI Script
# .gitlab-ci.yml
deploy:
stage: deploy
secrets:
DATABASE_PASSWORD:
vault: production/db/password@secret
API_KEY:
vault: production/api/key@secret
script:
- echo "Using secrets from Vault"
- ./deploy.sh
Step 1: Configure Vault Integration
- Navigate to: Project → Settings → CI/CD → Secure Files
- Configure JWT authentication with Vault
- Map CI/CD variables to Vault paths
6. Monitoring & Detection
6.1 Enable Audit Events
Profile Level: L1 (Baseline) NIST 800-53: AU-2, AU-3
Description
Configure comprehensive audit logging for GitLab operations.
ClickOps Implementation
Step 1: Configure Audit Event Streaming
- Navigate to: Group → Security & Compliance → Audit events
- Enable streaming to SIEM
- Configure: All event types
Step 2: Alert on Critical Events
- Repository deletion
- Protected branch modification
- Runner registration
- Admin privilege changes
Detection Queries
See the DB Code Pack below for SQL queries that detect unusual repository cloning and pipeline variable modifications.
Code Pack: API Script
# Query group-level audit events and verify audit logging is active.
# GitLab Premium/Ultimate exposes audit events via the REST API.
info "6.1 Retrieving recent audit events..."
AUDIT_EVENTS=$(gl_get "/groups/${GROUP_ID}/audit_events?per_page=20" 2>/dev/null) || {
fail "6.1 Failed to retrieve audit events -- requires GitLab Premium/Ultimate and admin token"
increment_failed
summary
exit 0
}
EVENT_COUNT=$(echo "${AUDIT_EVENTS}" | jq 'length' 2>/dev/null || echo "0")
info "6.1 Retrieved ${EVENT_COUNT} recent audit event(s)"
if [ "${EVENT_COUNT}" -gt 0 ]; then
# Show recent security-relevant events
echo "${AUDIT_EVENTS}" | jq -r '.[] | " - [\(.created_at)] \(.author.name // .author_id): \(.entity_type)/\(.details.action // .details.custom_message // "event")"' 2>/dev/null || true
# Check for key security event types
info "6.1 Checking for security-relevant event categories..."
AUTH_EVENTS=$(echo "${AUDIT_EVENTS}" | jq '[.[] | select(.details.action // "" | test("auth|login|session"; "i"))] | length' 2>/dev/null || echo "0")
PERM_EVENTS=$(echo "${AUDIT_EVENTS}" | jq '[.[] | select(.details.action // "" | test("permission|role|access"; "i"))] | length' 2>/dev/null || echo "0")
REPO_EVENTS=$(echo "${AUDIT_EVENTS}" | jq '[.[] | select(.details.action // "" | test("push|merge|branch|tag"; "i"))] | length' 2>/dev/null || echo "0")
info "6.1 Event breakdown: auth=${AUTH_EVENTS}, permissions=${PERM_EVENTS}, repository=${REPO_EVENTS}"
fi
# Check for audit event streaming destinations (L2)
if should_apply 2 2>/dev/null; then
info "6.1 L2: Checking external audit event streaming destinations..."
STREAM_DESTS=$(gl_get "/groups/${GROUP_ID}/audit_events/streaming/destinations" 2>/dev/null || echo "[]")
DEST_COUNT=$(echo "${STREAM_DESTS}" | jq 'length' 2>/dev/null || echo "0")
if [ "${DEST_COUNT}" -gt 0 ]; then
pass "6.1 Found ${DEST_COUNT} audit event streaming destination(s)"
echo "${STREAM_DESTS}" | jq -r '.[] | " - \(.destination_url // "unknown") (verification: \(.verification_token | if . then "set" else "unset" end))"' 2>/dev/null || true
else
warn "6.1 No external audit event streaming destinations configured"
warn "6.1 Configure via Settings > General > Audit events > Streaming to forward to your SIEM"
fi
fi
Code Pack: Sigma Detection Rule
detection:
selection_destination:
entity_type: 'ExternalAuditEventDestination'
action: 'destroy'
selection_header:
entity_type: 'AuditEventsStreamingHeader'
action: 'destroy'
condition: selection_destination or selection_header
fields:
- author_name
- entity_path
- target_details
- ip_address
- created_at
7. Compliance Quick Reference
SOC 2 Mapping
| Control ID | GitLab Control | Guide Section |
|---|---|---|
| CC6.1 | SSO enforcement | 1.1 |
| CC6.2 | Project permissions | 1.2 |
| CC7.2 | Audit events | 6.1 |
| CC8.1 | Protected branches | 1.2 |
NIST 800-53 Mapping
| Control | GitLab Control | Guide Section |
|---|---|---|
| IA-2(1) | SSO with MFA | 1.1 |
| AC-6 | Role-based access | 1.2 |
| CM-3 | Push rules | 4.1 |
| SC-28 | CI/CD variable protection | 2.1 |
Appendix A: Edition Compatibility
| Control | Free | Premium | Ultimate |
|---|---|---|---|
| SAML SSO | ❌ | ✅ | ✅ |
| Push Rules | Basic | ✅ | ✅ |
| Audit Events | ❌ | ✅ | ✅ |
| SAST/DAST | ❌ | ❌ | ✅ |
| Compliance Dashboard | ❌ | ❌ | ✅ |
Appendix B: References
Official GitLab Documentation:
API & Developer Tools:
Compliance Frameworks:
- SOC 2 Type II, SOC 3, ISO/IEC 27001:2022, ISO 27017, ISO 27018, PCI DSS (SAQ D) – via Trust Center
- External Audits, Certifications, and Attestations
Security Incidents:
- CVE-2023-7028 (Jan 2024): Critical account takeover vulnerability (CVSS 10.0) via password reset emails to unverified addresses; actively exploited in the wild. Patched in GitLab 16.7.2+.
- Red Hat Consulting GitLab Instance Breach (Sep 2025): Attacker accessed Red Hat’s self-managed GitLab CE instance, exposing consulting data for organizations such as Bank of America, T-Mobile, and U.S. government agencies. GitLab confirmed no breach of its managed SaaS infrastructure.
Community Resources:
Changelog
| Date | Version | Maturity | Changes | Author |
|---|---|---|---|---|
| 2026-02-19 | 0.1.2 | draft | Migrate all remaining inline code to Code Packs (2.1, 2.3, 3.1, 4.1, 4.2, 6.1); zero inline blocks | Claude Code (Opus 4.6) |
| 2026-02-19 | 0.1.1 | draft | Migrate inline code to CLI Code Packs (1.1, 3.1, 3.2, 5.1) | Claude Code (Opus 4.6) |
| 2025-12-14 | 0.1.0 | draft | Initial GitLab hardening guide | Claude Code (Opus 4.5) |