How it works
The explanation, with examples you can run.
A worked example
# .github/workflows/ci.ymlname: cion: pull_request: push: branches: [main] permissions: contents: read # the default token can read the repo, nothing more concurrency: group: ci-${{ github.ref }} cancel-in-progress: true # a new push to the same branch cancels the old run jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: node: [22, 24] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node }} cache: npm - run: npm ci - run: npm test build: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 cache: npm - run: npm ci - run: npm run build - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: dist path: dist/The test job fans out into two jobs, one per Node version. build waits for both (needs: test) and uploads dist/ as an artifact, which a later deploy job or workflow can download. Nothing else carries over between jobs: each starts on a clean runner.
Runners
GitHub-hosted runners (ubuntu-latest, windows-latest, macos-latest, plus larger and ARM sizes) are created for one job and thrown away. Self-hosted runners are your own machines. They can reach private networks and keep warm caches, but they can also keep state between jobs, so never let untrusted code (pull requests from forks of a public repository) run on them.
The token and permissions
Each job gets an automatic GITHUB_TOKEN. Set permissions at the top of the workflow to the least it needs (often contents: read) and widen it per job, for example packages: write only on the job that publishes.
Supply chain of the workflow itself
An action runs with whatever your job can reach: the token, secrets and the checked-out code. So:
- Pin third-party actions to a full commit SHA and keep the tag in a comment so people can read it; let Dependabot propose updates. GitHub now also has workflow dependency lockfiles (
.github/workflows/actions.lock, in public preview at the time of writing) that record the commit for every action, including transitive ones. - Don't paste untrusted input into scripts.
run: echo "${{ github.event.pull_request.title }}"puts a pull request title straight into a shell command. Pass it throughenv:and refer to"$TITLE"instead. - Be careful with
pull_request_target. It runs with secrets and a write token on the base repository. Never check out and run a fork's code from it.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.