Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .github/actions/ci-inputs/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
name: CI inputs
description: >-
Hash the files a job depends on (a list in .github/path-filters.yml plus the
workflow running it) and say whether that exact set already passed, so the
job can be skipped. Needs the repository checked out. After the job's work
succeeds, save the key with .github/actions/ci-passed.

# Two ways to ask:
# exact — has this set of inputs ever passed? Right for checks: the same
# files give the same result, so a CHANGELOG-only push, a re-push
# of the same code, or a docs-only PR (main's run is visible to
# PRs) is skipped. A cancelled or failed run saves nothing, so its
# inputs run again next time.
# latest — did the most recent pass have this set? Right for publishing:
# a revert to older code must publish again, because the `:main`
# image moved on since.
# Markers are tiny Actions caches. A PR can read its own and main's, never
# another PR's. An evicted marker only means the job runs again.

inputs:
filter:
description: A list name in .github/path-filters.yml.
required: true
match:
description: exact or latest (see above).
required: false
default: exact

outputs:
key:
description: The cache key to save with ci-passed once the job succeeds.
value: ${{ steps.hash.outputs.key }}
passed:
description: "'true' when these inputs already passed and the job can skip."
value: ${{ steps.decide.outputs.passed }}

runs:
using: composite
steps:
- name: Hash ${{ inputs.filter }} inputs
id: hash
shell: bash
env:
FILTER: ${{ inputs.filter }}
MATCH: ${{ inputs.match }}
REPOSITORY: ${{ github.repository }}
WORKFLOW_REF: ${{ github.workflow_ref }}
RUN: ${{ github.run_id }}-${{ github.run_attempt }}
run: |
set -euo pipefail
# owner/repo/.github/workflows/ci.yml@refs/... → .github/workflows/ci.yml
workflow="${WORKFLOW_REF#"${REPOSITORY}/"}"
workflow="${workflow%%@*}"
hash="$(bash scripts/ci-inputs.sh "${FILTER}" "${workflow}")"
case "${MATCH}" in
exact)
prefix="ci-passed-${FILTER}-${hash}"
key="${prefix}"
;;
latest)
prefix="ci-latest-${FILTER}-"
key="${prefix}${RUN}-${hash}"
;;
*)
echo "::error::match must be exact or latest (got ${MATCH})."
exit 1
;;
esac
echo "hash=${hash}" >> "${GITHUB_OUTPUT}"
echo "prefix=${prefix}" >> "${GITHUB_OUTPUT}"
echo "key=${key}" >> "${GITHUB_OUTPUT}"
echo "${FILTER} inputs (with ${workflow}): ${hash}"

- name: Look up ${{ inputs.filter }}
id: lookup
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .ci-passed
key: ${{ steps.hash.outputs.key }}
restore-keys: ${{ inputs.match == 'latest' && steps.hash.outputs.prefix || '' }}
lookup-only: true

- name: Decide
id: decide
shell: bash
env:
FILTER: ${{ inputs.filter }}
MATCH: ${{ inputs.match }}
HASH: ${{ steps.hash.outputs.hash }}
HIT: ${{ steps.lookup.outputs.cache-hit }}
MATCHED: ${{ steps.lookup.outputs.cache-matched-key }}
run: |
set -euo pipefail
passed=false
if [ "${MATCH}" = "exact" ] && [ "${HIT}" = "true" ]; then
passed=true
elif [ "${MATCH}" = "latest" ] && [ -n "${MATCHED}" ] && [ "${MATCHED%-"${HASH}"}" != "${MATCHED}" ]; then
passed=true
fi
echo "passed=${passed}" >> "${GITHUB_OUTPUT}"
if [ "${passed}" = "true" ]; then
echo "These ${FILTER} inputs already passed. Skipping."
else
echo "These ${FILTER} inputs have not passed yet. Running."
fi
27 changes: 27 additions & 0 deletions .github/actions/ci-passed/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: CI passed
description: >-
Remember that a job's inputs passed (the key from .github/actions/ci-inputs),
so the next run with the same inputs skips it. Put it last in the job, so it
only runs when everything before it succeeded.

inputs:
key:
description: The key output of ci-inputs.
required: true

runs:
using: composite
steps:
- name: Write the marker
shell: bash
env:
KEY: ${{ inputs.key }}
run: |
mkdir -p .ci-passed
printf '%s\n' "${KEY}" > .ci-passed/key

# The path must be the same as in ci-inputs, or the lookup never matches.
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .ci-passed
key: ${{ inputs.key }}
64 changes: 48 additions & 16 deletions .github/path-filters.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,35 @@
# Which expensive CI/CD jobs run for a change.
# Used by dorny/paths-filter in ci.yml and cd.yml.
# What each CI/CD job depends on. A job runs only when the files its list
# matches are not a set that already passed (scripts/ci-inputs.sh hashes them,
# .github/actions/ci-inputs looks the hash up). So a push that only adds a
# CHANGELOG entry, or a docs-only PR, does not run the app checks again.
# Keep the pipeline diagrams in docs/architecture.md and docs/runbook.md in sync.
#
# check → just check + just build
# image → Docker smoke (PR) / GHCR + Sentry (main)
# src/** (including tests) is in both: a test-only change still checks and publishes.
# Patterns are git glob pathspecs: `*` stays inside one directory, `**`
# crosses them, a leading `!` excludes. Every hash also covers this file,
# scripts/ci-inputs.sh, .github/actions/ and the workflow that runs the job,
# so don't list those here. List what the job reads, nothing more.
# `just ci-inputs <name>` prints what a list matches.
#
# check → just check + just build (ci.yml)
# image → Docker smoke (ci.yml) and GHCR + Sentry (cd.yml)
# docs → Biome on docs/ + the VitePress build (docs.yml)
# terraform → just tf-validate and the env plans (terraform.yml)

check:
- "src/**"
- "scripts/**"
- "content/**"
- "config/**"
- "package.json"
# vitest.config.ts; Biome lints the rest.
- "*.ts"
# package.json, biome.json, tsconfig*.json
- "*.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "tsconfig.json"
- "tsconfig.*.json"
- "biome.json"
- "vitest.config.*"
- "justfile"
- ".nvmrc"
- ".github/workflows/ci.yml"
- ".github/path-filters.yml"
# Biome skips what git ignores.
Comment thread
NinjaLikesCheez marked this conversation as resolved.
- ".gitignore"
# scripts/secrets-manifest.test.ts checks these against infra/secrets.manifest.yaml (ADR 0015).
- "infra/secrets.manifest.yaml"
- "infra/modules/pixel/*.tf"
Expand All @@ -30,18 +38,42 @@ check:
- "docs/runbook.md"
- "docs/architecture.md"
- "docs/infra.md"
# scripts/ci-inputs.test.ts checks these against the lists below (ci.yml is
# in every check hash already).
- "Dockerfile"
- ".github/workflows/cd.yml"
- ".github/workflows/docs.yml"
- ".github/workflows/terraform.yml"

# What the Dockerfile copies. Tests are copied but never compiled into the image.
image:
- "src/**"
- "!src/**/*.test.ts"
- "!src/testing/**"
- "content/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "tsconfig.json"
- "tsconfig.*.json"
- "tsconfig.build.json"
- "Dockerfile"
- ".dockerignore"

# The site, plus the root package.json (its version is in the nav, and Biome
# comes from the root install). docs.yml's paths trigger must list these too.
docs:
- "docs/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "biome.json"
- ".gitignore"
- ".nvmrc"
- "justfile"

# Markdown under infra/ does not change the stack. terraform.yml's paths
# trigger must list these too.
terraform:
- "infra/**"
- "!infra/**/*.md"
- "justfile"
- ".github/workflows/ci.yml"
- ".github/workflows/cd.yml"
- ".github/path-filters.yml"
68 changes: 26 additions & 42 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
name: CD

# Merges to `main` publish GHCR and upload Sentry maps when an image path
# changed (dorny/paths-filter + .github/path-filters.yml). They do **not**
# roll pixel-prod (ADR 0013): that is the Release workflow. Docs / changelog /
# infra-only merges skip this pipeline. Azure `dev` stays a local build
# (`just deploy-dev`). Never copy pixel-dev / Mini Discord tokens into prod.
# Merges to `main` publish GHCR and upload Sentry maps when the image inputs
# (the `image` list in .github/path-filters.yml plus this file) differ from
# the last run that published. They do **not** roll pixel-prod (ADR 0013):
# that is the Release workflow. Docs / changelog / test / infra-only merges
# skip this pipeline. A run that didn't finish saves nothing, so the next one
# publishes. Azure `dev` stays a local build (`just deploy-dev`).
# Never copy pixel-dev / Mini Discord tokens into prod.
# This workflow does not apply Terraform.

on:
Expand All @@ -17,11 +19,10 @@ on:
permissions:
contents: read

# Queue, never cancel: cancelling a run mid-publish (say, for a docs-only
# merge right after it) left that commit with no GHCR image and no Sentry
# release. GitHub still keeps only one pending run and replaces it with a
# newer push, so the path filter below compares against the last green run,
# not just the previous push, and a replaced run's changes are not lost.
# Queue, never cancel, so a publish is never cut short halfway. GitHub still
# keeps only one pending run and replaces it with a newer push; that is safe
# because the `latest` lookup below compares against the last run that
# published, not the previous push, so a replaced run's changes are not lost.
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: false
Expand All @@ -34,46 +35,24 @@ env:

jobs:
changes:
name: Path filter
name: What changed
runs-on: ubuntu-latest
timeout-minutes: 2
# actions: read lists earlier CD runs to find the last green one.
permissions:
contents: read
actions: read
outputs:
image: ${{ steps.filter.outputs.image }}
image: ${{ steps.image.outputs.passed != 'true' }}
image-key: ${{ steps.image.outputs.key }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0

# A run is green only if it published whatever needed publishing, so
# everything after its commit is still unpublished. Falls back to the
# previous push when there is no green run, or it is not in main's history.
- name: Last green CD commit
id: base
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
last="$(gh api "repos/${REPO}/actions/workflows/cd.yml/runs?branch=main&event=push&status=success&per_page=1" --jq '.workflow_runs[0].head_sha // empty' || true)"
if [ -n "${last}" ] && [ "${last}" != "${GITHUB_SHA}" ] && git merge-base --is-ancestor "${last}" HEAD; then
base="${last}"
else
base="${BEFORE}"
fi
echo "sha=${base}" >> "${GITHUB_OUTPUT}"
echo "Image paths are compared against ${base}."

- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
# latest, not exact: after a revert the older code must publish again,
# because `:main` has moved on since it was last published.
- uses: ./.github/actions/ci-inputs
id: image
with:
base: ${{ steps.base.outputs.sha }}
filters: .github/path-filters.yml
filter: image
match: latest

publish:
name: Publish GHCR
Expand Down Expand Up @@ -161,7 +140,7 @@ jobs:

sentry-release:
name: Sentry release (source maps)
needs: publish
needs: [changes, publish]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
Expand Down Expand Up @@ -222,3 +201,8 @@ jobs:
set_commits: auto
ignore_missing: true
disable_telemetry: true

# Last, so a run counts as published only with its image and its maps.
- uses: ./.github/actions/ci-passed
with:
key: ${{ needs.changes.outputs.image-key }}
10 changes: 3 additions & 7 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,21 @@ jobs:
check:
name: Require CHANGELOG.md
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false

- uses: pnpm/action-setup@d9184bf108216479bc5a137cc391f4d7b14c870b # v6.1.0

# No pnpm install: the gate only uses Node built-ins, and Node runs the
# TypeScript itself.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm

- uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Require a changelog entry unless skip-changelog
env:
SKIP_CHANGELOG: ${{ contains(github.event.pull_request.labels.*.name, 'skip-changelog') }}
Expand Down
Loading
Loading