Secrets, Environment Variables, and Artifacts

8 min read

A CI pipeline that can only test a static, hardcoded URL isn't useful in practice. Real test suites need to reach a staging environment behind authentication, use API keys that can't be committed to source code, and produce reports that engineers can download and read after a failure. This lesson covers the three mechanisms that make workflows practical: secrets for sensitive values, environment variables for configuration, and artifacts for preserving test output.

Secrets

A secret is a piece of sensitive data — a password, an API key, a staging URL that shouldn't be public — stored encrypted in GitHub and injected into your workflow at runtime without ever appearing in source code or workflow logs.

Adding a secret:

  1. Repository → SettingsSecrets and variablesActionsNew repository secret
  2. Give it a name (all caps, underscores): STAGING_URL, API_KEY, DB_PASSWORD
  3. Paste the value. GitHub encrypts it immediately — you cannot read it back

Using a secret in a workflow:

steps:
  - name: Run API tests
    run: mvn clean test -DsuiteFile=api-smoke.xml -B
    env:
      BASE_URL:    ${{ secrets.STAGING_URL }}
      API_KEY:     ${{ secrets.API_KEY }}
      DB_PASSWORD: ${{ secrets.DB_PASSWORD }}

Secrets are masked in logs. Any log line that would print the secret value shows *** instead. This is automatic — no extra configuration needed.

The fork restriction. Pull requests from forked repositories do not receive secrets by default. This is a deliberate security boundary — a contributor from outside your organisation could write a workflow step to exfiltrate secrets. GitHub blocks this by withholding them. If you need to test PRs from forks, use pull_request_target with extreme caution, or require manual approval via Environments (see below).

Environment variables

For non-sensitive configuration — feature flags, environment names, headless mode switches — use environment variables instead of secrets:

env:
  NODE_ENV: test
  HEADLESS: 'true'
  TEST_TIMEOUT: '30000'
 
jobs:
  test:
    runs-on: ubuntu-latest
    env:
      BASE_URL: https://staging.example.com    # job-level; overrides workflow-level
    steps:
      - run: npm test
        env:
          LOG_LEVEL: debug                     # step-level; overrides job-level

Environment variables follow a precedence hierarchy: step-level overrides job-level overrides workflow-level. Use this to set sensible defaults at the workflow level and override them for specific jobs or steps.

GitHub Environments

For production deployments, GitHub Environments add an approval layer on top of secrets:

  1. Repository → SettingsEnvironmentsNew environment → name it production
  2. Add Required reviewers — the workflow pauses and asks a named person to approve before continuing
  3. Add Deployment branches — only main can deploy to production
  4. Set environment-specific secrets that only jobs referencing this environment can access
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production       # activates approval gate + environment secrets
    steps:
      - run: ./deploy.sh
        env:
          PROD_DB: ${{ secrets.PROD_DB_CONNECTION }}   # only available in this environment

For QA workflows, the most useful application is creating a staging environment with its own STAGING_URL and STAGING_API_KEY secrets, separate from production secrets. Tests that run against staging reference environment: staging and automatically get the right credentials.

Artifacts

Artifacts are files your workflow produces — test reports, screenshots, log files, coverage output — that you want to download and review after a run. They're stored by GitHub and expire after a configurable number of days.

- name: Upload test reports
  if: always()                               # critical — upload even when tests fail
  uses: actions/upload-artifact@v4
  with:
    name: test-reports-${{ github.run_id }}  # unique name per run
    path: |
      target/surefire-reports/               # multiple paths with YAML block scalar
      target/allure-results/
      target/screenshots/
    retention-days: 30
    compression-level: 6

Downloading artifacts from another job in the same workflow:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
 
  publish-report:
    needs: test
    runs-on: ubuntu-latest
    if: always()
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: playwright-report
          path: ./reports
      - run: ls -la ./reports    # do something with the report — publish, email, etc.

Best practices for secrets and artifacts

PracticeWhy
Always if: always() on report uploadsReports are most useful when tests fail — don't skip the upload on failure
Include ${{ github.run_id }} in artifact namesMakes it easy to correlate a downloaded report with a specific workflow run
Set retention-days explicitlyDefault is 90 days; 7–30 is usually sufficient and saves storage
Never echo a secretEven masked, avoid printing secrets to logs — a GitHub Actions update could change masking behaviour
Use environment-scoped secrets for prodKeeps production credentials out of workflows that run on feature branches

⚠️ Common mistakes

  • Hardcoding staging URLs in the workflow file. A staging URL in the YAML is not a secret — but it is a coupling between your CI config and your infrastructure. If the URL changes, you update one secret, not 15 workflow files. Use secrets for URLs too, even if they're not sensitive.
  • Uploading artifacts only if: failure() for reports. Reports are useful on green runs too — for auditing, trend tracking, and catching flakiness that doesn't cause outright failure. Use if: always() for reports and if: failure() only for large per-run assets like videos.
  • Not naming artifacts uniquely in a matrix. In a matrix build, every job runs the same steps. Without a matrix variable in the artifact name (name: report-${{ matrix.browser }}), each job overwrites the previous job's artifact. You get only the last one uploaded.

🎯 Practice task

Wire secrets and artifacts into your workflow — 30 minutes.

  1. Create a repository secret called STAGING_URL with a value (even if it's just https://example.com for practice).
  2. Update your test workflow to pass STAGING_URL as an environment variable to the test step.
  3. Configure your test framework to read the base URL from the environment (process.env.STAGING_URL for Node.js, System.getenv("STAGING_URL") for Java, os.environ.get("STAGING_URL") for Python).
  4. Add an artifact upload for your test reports with if: always() and retention-days: 14.
  5. Run a failing test. Confirm the artifact still uploads. Download it and open the report.
  6. Stretch: create a staging GitHub Environment with its own STAGING_URL secret. Update your workflow to reference environment: staging. Confirm the environment-scoped secret takes precedence over the repository-level secret.

You've now built a complete, production-quality PR test pipeline for GitHub Actions: automated tests on every PR, cross-browser matrix builds, and secure secret management with downloadable reports. The next chapter covers Jenkins — a different tool with different YAML (actually Groovy), different plugins, and different strengths for teams with on-premise CI requirements.

// tip to track lessons you complete and pick up where you left off across devices.