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:
- Repository → Settings → Secrets and variables → Actions → New repository secret
- Give it a name (all caps, underscores):
STAGING_URL,API_KEY,DB_PASSWORD - 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-levelEnvironment 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:
- Repository → Settings → Environments → New environment → name it
production - Add Required reviewers — the workflow pauses and asks a named person to approve before continuing
- Add Deployment branches — only
maincan deploy toproduction - 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 environmentFor 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: 6Downloading 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
| Practice | Why |
|---|---|
Always if: always() on report uploads | Reports are most useful when tests fail — don't skip the upload on failure |
Include ${{ github.run_id }} in artifact names | Makes it easy to correlate a downloaded report with a specific workflow run |
Set retention-days explicitly | Default is 90 days; 7–30 is usually sufficient and saves storage |
Never echo a secret | Even masked, avoid printing secrets to logs — a GitHub Actions update could change masking behaviour |
| Use environment-scoped secrets for prod | Keeps 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. Useif: always()for reports andif: 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.
- Create a repository secret called
STAGING_URLwith a value (even if it's justhttps://example.comfor practice). - Update your test workflow to pass
STAGING_URLas an environment variable to the test step. - Configure your test framework to read the base URL from the environment (
process.env.STAGING_URLfor Node.js,System.getenv("STAGING_URL")for Java,os.environ.get("STAGING_URL")for Python). - Add an artifact upload for your test reports with
if: always()andretention-days: 14. - Run a failing test. Confirm the artifact still uploads. Download it and open the report.
- Stretch: create a
stagingGitHub Environment with its ownSTAGING_URLsecret. Update your workflow to referenceenvironment: 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.