Steve Kinney

Write the CI Workflow from Scratch: Solution

This is another hybrid lab. The workflow files don’t ship in the Shelf starter, but the jobs still map to real local commands. The hosted parts—artifact uploads, cron triggers, required checks—need a GitHub remote with Actions enabled. I will keep those two truths separate so the solution does not pretend local YAML parsing is the same thing as a real CI run.

What to add

main.yml: the three-job pipeline

Open .github/workflows/main.yml. The workflow runs on every push and pull_request and requests only contents: read. Good. Keep it boring.

The static job

static:
  name: Static layer
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v6
    - uses: actions/setup-node@v6
      with:
        node-version: '24'
    - name: Cache dependencies
      uses: actions/cache@v5
      with:
        path: |
          ~/.npm
          ~/.cache/ms-playwright
        key: ${{ runner.os }}-deps-${{ hashFiles('package-lock.json') }}-playwright-${{ hashFiles('playwright.config.ts') }}
    - name: Install dependencies
      run: npm ci --ignore-scripts
    - name: Lint
      run: npm run lint
    - name: Typecheck
      run: npm run typecheck
    - name: Dead code
      run: npm run knip
    - name: Install gitleaks
      run: |
        curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz \
          | tar -xz -C /tmp gitleaks
        sudo install /tmp/gitleaks /usr/local/bin/gitleaks
        gitleaks version
    - name: Secret scan
      run: gitleaks dir . --redact --config .gitleaks.toml

This is the fuller post-static-layer version. If you have not built knip or gitleaks yet, drop those two steps for the first pass and add them back later. The point is not cargo-culting the exact YAML. The point is gating cheap failures before you spend browser minutes.

These are steps, not separate jobs, because the setup overhead is shared. Splitting lint, typecheck, knip, and gitleaks into four jobs buys you more cold starts, not more insight.

The unit job

unit:
  name: Unit tests
  runs-on: ubuntu-latest
  needs: static
  steps:
    - uses: actions/checkout@v6
    - uses: actions/setup-node@v6
      with:
        node-version: '24'
    - name: Cache dependencies
      uses: actions/cache@v5
      with:
        path: |
          ~/.npm
          ~/.cache/ms-playwright
        key: ${{ runner.os }}-deps-${{ hashFiles('package-lock.json') }}-playwright-${{ hashFiles('playwright.config.ts') }}
    - name: Install dependencies
      run: npm ci --ignore-scripts
    - name: Run Vitest
      run: npm run test:unit

needs: static is the first load-bearing decision in the file. If lint or typecheck already failed, there is no reason to burn another runner executing unit tests against broken code.

The end-to-end job

end-to-end:
  name: End-to-end tests
  runs-on: ubuntu-latest
  needs: static
  steps:
    - uses: actions/checkout@v6
    - uses: actions/setup-node@v6
      with:
        node-version: '24'
    - name: Cache dependencies
      uses: actions/cache@v5
      with:
        path: |
          ~/.npm
          ~/.cache/ms-playwright
        key: ${{ runner.os }}-deps-${{ hashFiles('package-lock.json') }}-playwright-${{ hashFiles('playwright.config.ts') }}
    - name: Install dependencies
      run: npm ci --ignore-scripts
    - name: Install Playwright browsers
      run: npx playwright install --with-deps chromium
    - name: Create .env for preview server
      run: |
        cat > .env <<'EOF'
        DATABASE_URL=file:./ci.db
        OPEN_LIBRARY_BASE_URL=https://openlibrary.org
        EOF
    - name: Run Playwright
      run: npm run test
    - name: Generate failure dossier
      if: failure()
      run: npm run dossier
    - name: Upload Playwright report
      if: failure()
      uses: actions/upload-artifact@v7
      with:
        name: playwright-report
        path: playwright-report/
        retention-days: 7
    - name: Upload failure dossier
      if: failure()
      uses: actions/upload-artifact@v7
      with:
        name: failure-dossier
        path: playwright-report/dossier.md
        retention-days: 7

Three details matter here.

First: npm run test, not npm run test:e2e. The current Shelf starter’s Playwright command surface is test, and the solution should not invent a second script just because an older version of the repo did.

Second: DATABASE_URL=file:./ci.db. The current starter does not need tmp/ci.db or a pre-created tmp/ directory to boot the preview server. Keep the CI env small and real.

Third: the dossier upload is conditional and separate. The HTML report is large. The markdown dossier is small. An agent should be able to grab the small one first and only pull the full report if it needs the traces.

If you have not completed the dossier lab yet, omit the dossier step and its artifact upload on the first pass. The Playwright report artifact still buys you most of the debugging value.

nightly.yml lives in the appendix

The nightly workflow is not part of this lab. It lands in the appendix labs (Lab: Add Cross-Browser Coverage and Lab: Add a Nightly Verification Workflow) alongside the commands it actually runs. A standalone placeholder is worse than no file at all — it invites cargo-culted job names and silent schedule drift. Build nightly.yml when you build the things it gates.

What you still need to run

Locally, validate the YAML:

python3 -c "import yaml; yaml.safe_load(open('.github/workflows/main.yml'))"

Then run the commands the jobs map to:

npm run lint
npm run typecheck
npm run knip
npm run test:unit
npm run test

If you wired the dossier already, you can sanity-check the command too:

npm run dossier 2>/dev/null || echo "dossier only becomes interesting after a failure"

And if the gitleaks CLI is installed locally:

gitleaks dir . --redact --config .gitleaks.toml

Local vs. hosted

Local: you can prove the workflow files parse and that every named command behaves the way the jobs expect.

Hosted: actual workflow runs, artifact downloads, required checks, and the cron trigger still need a GitHub repository with Actions enabled. Do not claim the artifact loop works until you have actually downloaded an artifact from a real run.

Patterns to take away

  • One gate, then fan out. static blocks unit and end-to-end, which saves runner time and gets faster red builds.
  • Use the repository’s real command surface. If the starter says npm run test, the workflow says npm run test.
  • Keep CI env small and explicit. Add only the variables the app reads today.
  • Upload the dossier separately when you have it. Small artifacts make agent recovery faster.
  • Nightly exists to keep broad or slow checks out of the fast PR loop. Build the real nightly jobs when the appendix labs add the commands they depend on.

Additional Reading

Last modified on .