diff --git a/.github/actions/fetch-test-artifacts/action.yml b/.github/actions/fetch-test-artifacts/action.yml index 0cfe706b..7a62e82e 100644 --- a/.github/actions/fetch-test-artifacts/action.yml +++ b/.github/actions/fetch-test-artifacts/action.yml @@ -1,6 +1,6 @@ # Restores the two fetched, gitignored test artifacts (trimmed OpenAPI spec, GraphQL schema) # from cache, fetching each on a miss: the ONE place an artifact is cached (the drift-tripwire -# nightlies fetch uncached by design). Needs setup-bun and `bun install` earlier in the job. +# nightlies fetch uncached by design). Needs ./.github/actions/setup (bun and the install) earlier in the job. name: Fetch the test artifacts description: Restore the trimmed OpenAPI spec and the GraphQL schema from cache, fetching each on a miss diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 00000000..9a71107b --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,32 @@ +# The bun setup and the dependency install of every repo-owned workflow job; the caller keeps its own checkout and +# setup-node, whose options differ per job. +# install -> --ignore-scripts: scripts.prepare installs lefthook, a git hook no runner uses; "false" (any letter +# case, as GitHub compares) skips the install for a job that runs a bare script or resolves the lockfile itself +# yamllint -> "true" installs the pinned yamllint behind `bun run lint:yaml`, which fails a CI run where it is missing +name: Set up bun +description: The pinned bun, the locked dependencies unless told not to, and yamllint for the jobs that lint YAML + +inputs: + install: + description: '"false" sets up bun alone; anything else runs bun install --frozen-lockfile --ignore-scripts' + default: "true" + yamllint: + description: '"true" installs the pinned yamllint for lint:yaml' + default: "false" + +runs: + using: composite + steps: + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + - name: Install dependencies + if: inputs.install != 'false' + shell: bash + run: bun install --frozen-lockfile --ignore-scripts + # pipx ships on the ubuntu runner image with its bin directory on PATH; a venv of its own leaves the runner's + # python untouched. + - name: Install yamllint + if: inputs.yamllint == 'true' + shell: bash + run: pipx install yamllint==1.38.0 diff --git a/.github/workflows/auto-fix.yml b/.github/workflows/auto-fix.yml index 665c3799..f1e9d924 100644 --- a/.github/workflows/auto-fix.yml +++ b/.github/workflows/auto-fix.yml @@ -68,11 +68,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.ref }} persist-credentials: false - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version - - name: Install dependencies - run: bun install --frozen-lockfile --ignore-scripts + - uses: ./.github/actions/setup - name: Graduate upstream gaps octokit now ships shell: bash run: bun .github/scripts/graduate-upstream-gaps.ts diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml index 447bc727..f3cb5857 100644 --- a/.github/workflows/auto-format.yml +++ b/.github/workflows/auto-format.yml @@ -3,6 +3,9 @@ # commit to the PR branch, then removes the label. Generated once by # the fleet sync and never overwritten by a later # sync: adjust the format commands to this repository's tooling. +# +# Formatting runs PR-controlled code (the locked biome, the lint:fix script), so it happens in a job without a write +# token and crosses to the push job as a git patch; applying a patch executes nothing, as in auto-fix.yml. name: Auto Format @@ -13,42 +16,95 @@ on: permissions: contents: read +# One run per PR at a time: the label removed and re-applied while a run is pushing would start a second run against +# the same branch, and two formatting pushes race. The later run queues and formats the pushed head instead. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: false + jobs: format: - # Same-repo PRs only: GITHUB_TOKEN cannot push to a fork's branch, and - # PR-controlled tool configuration should not run next to write - # credentials (checkout below persists none; the push step scopes them). + # Same-repo PRs only: GITHUB_TOKEN cannot push to a fork's branch. if: github.event.label.name == 'fix-lint' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + changed: ${{ steps.format.outputs.changed }} + head: ${{ steps.format.outputs.head }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + persist-credentials: false + # The install puts the locked biome in node_modules, so the format runs the gate's biome, not the newest one. + - uses: ./.github/actions/setup + - name: Format (biome) and stage the patch + id: format + run: | + # The patch belongs to this commit alone; the push job refuses any other head. + echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + bun run lint:fix + # Anything the formatter left staged is not this workflow's fix: start from an empty index, then stage + # modifications and deletions of tracked files alone (a formatter adds nothing). + git reset -q + git add -u + git diff --cached --binary > "$RUNNER_TEMP/format.patch" + if [ -s "$RUNNER_TEMP/format.patch" ]; then + git diff --cached --stat + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "nothing to format" + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: steps.format.outputs.changed == 'true' + with: + name: format-patch + path: ${{ runner.temp }}/format.patch + if-no-files-found: error + + # Runs whenever the format job ran, so the label comes off even after a failed format: the label is a one-shot + # request, and a broken run must not loop. + push: + needs: format + if: always() && needs.format.result != 'skipped' + runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write pull-requests: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: needs.format.outputs.changed == 'true' with: ref: ${{ github.event.pull_request.head.ref }} persist-credentials: false - - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - - name: Format (biome) - run: bun x @biomejs/biome check --write . - - - name: Commit and push changes + # The patch lands OUTSIDE the checkout so nothing in the work tree can shadow or stage it. + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + if: needs.format.outputs.changed == 'true' + with: + name: format-patch + path: ${{ runner.temp }}/format + - name: Commit and push the formatting + if: needs.format.outputs.changed == 'true' env: GH_TOKEN: ${{ github.token }} HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_SHA: ${{ needs.format.outputs.head }} run: | - if git diff --quiet; then - echo "nothing to format" - else - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "style: apply automated formatting" - git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:"$HEAD_REF" + # The branch can move between the two jobs; the patch was cut on HEAD_SHA and formats that tree alone, so a + # newer head gets its own run (re-apply the label) instead of a commit formatting the old one. + if [ "$(git rev-parse HEAD)" != "$HEAD_SHA" ]; then + echo "::notice::head moved since the format; skipping the stale formatting push" + exit 0 fi - # The label is a one-shot request: remove it even when a step failed, - # so a broken run cannot loop. + git apply --index --binary "$RUNNER_TEMP/format/format.patch" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit --no-verify -m "style: apply automated formatting" + # The lease pins the remote to the commit the patch was cut on, so a push that lands in between rejects this one. + git push --force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" \ + "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:"refs/heads/${HEAD_REF}" - name: Remove the fix-lint label if: always() env: diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e5a24db5..1f1fc676 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -12,18 +12,19 @@ on: jobs: check: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: ./.github/actions/setup with: - bun-version-file: .bun-version - - name: Install dependencies - run: bun install --frozen-lockfile + yamllint: "true" # bun test loads the fetched, gitignored OpenAPI spec and GraphQL # schema; the composite restores each from cache or fetches on a miss. - uses: ./.github/actions/fetch-test-artifacts - name: Lint (biome) run: bun run lint + - name: Lint (yaml) + run: bun run lint:yaml - name: Lint (architecture) run: bun run lint:arch - name: Typecheck @@ -50,9 +51,9 @@ jobs: with: # The newest release merge can be arbitrarily far behind HEAD. fetch-depth: 0 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: ./.github/actions/setup with: - bun-version-file: .bun-version + install: "false" - name: Confirm last-release-sha matches the newest release merge run: bun .github/scripts/release-pipeline.ts boundary-check @@ -63,21 +64,18 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: ./.github/actions/setup with: - bun-version-file: .bun-version + install: "false" - name: Require the release PR to carry this cycle's anchor run: bun .github/scripts/release-pipeline.ts anchor-check schema-check: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version - - name: Install dependencies - run: bun install --frozen-lockfile + - uses: ./.github/actions/setup - name: Regenerate the schema and compare run: bun run build:check @@ -87,15 +85,14 @@ jobs: # than the floor, so the PATH node is 24 and the floor's binary is handed to the consumer alone. package-smoke: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: node-version: ["22.14", "24"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version + - uses: ./.github/actions/setup - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node-version }} @@ -104,8 +101,6 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - - name: Install dependencies - run: bun install --frozen-lockfile - name: Build, pack, install, import, and typecheck the library run: bun .github/scripts/package-smoke.ts @@ -113,13 +108,10 @@ jobs: # exception never sets the result output. self-check: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version - - name: Install dependencies - run: bun install --frozen-lockfile --ignore-scripts + - uses: ./.github/actions/setup - name: Build the action bundle run: bun run build:bundle - name: Run settings-as-code (check mode) @@ -142,18 +134,15 @@ jobs: # to diff against, so it runs the full corpus. e2e-smoke: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version + - uses: ./.github/actions/setup - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - - name: Install dependencies - run: bun install --frozen-lockfile - name: Select sections from the diff id: select env: @@ -188,18 +177,15 @@ jobs: # the selector finds nothing settings-related in the diff. endpoint-coverage: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version + - uses: ./.github/actions/setup - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - - name: Install dependencies - run: bun install --frozen-lockfile # Only the selector's "none" answer matters here. It runs BEFORE the spec cache/fetch so a PR that selects # nothing skips the download too. - name: Check whether the diff can affect route coverage diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index f3ef7f28..e7c8367e 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -21,10 +21,8 @@ permissions: jobs: copilot-setup-steps: runs-on: ubuntu-latest + # The most Copilot accepts for this job (its documented ceiling); the setup itself takes seconds. timeout-minutes: 59 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - - name: Install dependencies (bun) - run: bun install --frozen-lockfile + - uses: ./.github/actions/setup diff --git a/.github/workflows/e2e-nightly.yml b/.github/workflows/e2e-nightly.yml index e48b19eb..04dc1bc1 100644 --- a/.github/workflows/e2e-nightly.yml +++ b/.github/workflows/e2e-nightly.yml @@ -1,3 +1,8 @@ +# The nightly run of the whole curated e2e corpus (test/e2e/run.ts) plus the endpoint-coverage tripwire, against an +# OpenAPI spec fetched fresh: PR CI restores a cached spec for speed, so this fetch is where upstream drift surfaces. +# A red night files or updates one tracking issue through the fleet's fuzz-issue action; a green one closes it. +# Repo-owned, never overwritten by sync. + name: E2E Nightly on: @@ -18,16 +23,13 @@ concurrency: jobs: nightly: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version + - uses: ./.github/actions/setup - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - - name: Install dependencies - run: bun install --frozen-lockfile # The trimmed OpenAPI spec is a fetched, gitignored artifact. The nightly # ALWAYS fetches it (no cache) on purpose: the fetch is the drift tripwire. diff --git a/.github/workflows/nightly-fuzz.yml b/.github/workflows/nightly-fuzz.yml index 94c1e1e5..689500b9 100644 --- a/.github/workflows/nightly-fuzz.yml +++ b/.github/workflows/nightly-fuzz.yml @@ -52,14 +52,10 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version + - uses: ./.github/actions/setup - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 - - name: Install dependencies - run: bun install --frozen-lockfile # The trimmed OpenAPI spec is a fetched, gitignored artifact the mock # validates responses against. Always fetched fresh (no cache), same as diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 3149cbff..eeac86ca 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -30,15 +30,11 @@ permissions: jobs: checks: runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version - - name: Install dependencies - id: install - run: bun install --frozen-lockfile --ignore-scripts + - uses: ./.github/actions/setup + id: setup # PR CI proves USED_PATHS against the pinned descriptor; this probe # re-cuts against upstream HEAD (deliberately bypassing the cached @@ -74,12 +70,12 @@ jobs: fi # always() so a red spec probe cannot mask this one (the job still - # fails if either step failed); gated on install because there is - # nothing to probe when setup itself broke. --no-save keeps the + # fails if either step failed); gated on the setup step because there + # is nothing to probe when setup itself broke. --no-save keeps the # lockfile and package.json untouched; the upgraded node_modules stays # ambient for the rest of this job, so keep later steps out of it. - name: Probe the latest @octokit/types - if: ${{ !cancelled() && steps.install.outcome == 'success' }} + if: ${{ !cancelled() && steps.setup.outcome == 'success' }} run: | bun add --no-save --ignore-scripts @octokit/types@latest log="$(mktemp)" @@ -111,12 +107,14 @@ jobs: # probe needs a clean tree to re-resolve from. float-canary: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + # The gate below runs lint:yaml; the from-scratch resolve is this job's install. + - uses: ./.github/actions/setup with: - bun-version-file: .bun-version + install: "false" + yamllint: "true" # Delete before resolving, exactly like the Dependabot workflow: bun # keeps a stale lockfile as a valid resolution, and only a # from-scratch resolve floats what that workflow would float. diff --git a/.github/workflows/post-green.yml b/.github/workflows/post-green.yml index d8f9c06a..ce4c7b60 100644 --- a/.github/workflows/post-green.yml +++ b/.github/workflows/post-green.yml @@ -59,14 +59,11 @@ jobs: echo "proceed=false" >> "$GITHUB_OUTPUT" fi rm -f probe.err - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: ./.github/actions/setup if: steps.token.outputs.proceed == 'true' - with: - bun-version-file: .bun-version - name: Build the bundle and the library if: steps.token.outputs.proceed == 'true' run: | - bun install --frozen-lockfile --ignore-scripts bun run build:bundle bun run build:lib - name: Append this commit's packaged commit to build and point latest at the newest main source @@ -112,10 +109,8 @@ jobs: # The version counts main's commits under this one and the verdict places published sources by ancestry; a shallow checkout can do neither. fetch-depth: 0 persist-credentials: false - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: ./.github/actions/setup if: steps.oidc.outputs.proceed == 'true' - with: - bun-version-file: .bun-version # registry-url writes the .npmrc npm publishes through; with no # NODE_AUTH_TOKEN the action leaves a placeholder there, which npm's # OIDC exchange replaces. @@ -140,9 +135,7 @@ jobs: fi - name: Build the library if: steps.oidc.outputs.proceed == 'true' - run: | - bun install --frozen-lockfile --ignore-scripts - bun run build:lib + run: bun run build:lib # scripts.prepare is dropped from the published manifest: it installs # lefthook, a devDependency the tarball does not carry, and npm blocks # install scripts from a provenance-attested package anyway. diff --git a/.github/workflows/update-release-pr.yml b/.github/workflows/update-release-pr.yml index b7c5caeb..5dd36652 100644 --- a/.github/workflows/update-release-pr.yml +++ b/.github/workflows/update-release-pr.yml @@ -28,8 +28,8 @@ jobs: contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: ./.github/actions/setup with: - bun-version-file: .bun-version + install: "false" - name: Anchor the boundary inside the release PR branch run: bun .github/scripts/release-pipeline.ts anchor diff --git a/.github/workflows/update-release.yml b/.github/workflows/update-release.yml index 2aae2835..afac819e 100644 --- a/.github/workflows/update-release.yml +++ b/.github/workflows/update-release.yml @@ -65,12 +65,9 @@ jobs: ref: ${{ steps.source.outputs.sha }} # The script judges whether the build tip's source lies on main; a shallow checkout cannot answer that. fetch-depth: 0 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version + - uses: ./.github/actions/setup - name: Build the bundle and the schema run: | - bun install --frozen-lockfile --ignore-scripts bun run build # The schema asset and the raw URLs at the tags must serve the bytes committed at the merge commit; a # divergent regeneration (generator drift, or a compromised generator) stops the release here. @@ -102,9 +99,9 @@ jobs: contents: write # read-only in spirit; write is what makes drafts visible to gh steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: ./.github/actions/setup with: - bun-version-file: .bun-version + install: "false" - name: Confirm the published refs carry the build outputs env: TAG: ${{ inputs.tag }} @@ -155,9 +152,7 @@ jobs: with: ref: ${{ needs.package-release.outputs.source-sha }} persist-credentials: false - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version-file: .bun-version + - uses: ./.github/actions/setup # registry-url writes the .npmrc npm publishes through; with no # NODE_AUTH_TOKEN the action leaves a placeholder there, which npm's # OIDC exchange replaces. @@ -179,9 +174,7 @@ jobs: exit 1 fi - name: Build the library - run: | - bun install --frozen-lockfile --ignore-scripts - bun run build:lib + run: bun run build:lib # scripts.prepare is dropped from the published manifest, as in # post-green.yml's publish-next. # The verdict holds the built package.json to the tag and reads the diff --git a/package.json b/package.json index b7c74cd5..0365ed8a 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "check": "bun run lint && bun run lint:yaml && bun run lint:arch && bun run check:compat && bun run typecheck && bun run knip && bun run test && bun run build:check", "check:compat": "bun .github/scripts/check-compat-markers.ts", "lint": "bun x biome ci --error-on-warnings .", - "lint:yaml": "if command -v yamllint >/dev/null 2>&1; then git ls-files -z '*.yml' '*.yaml' | xargs -0 yamllint -s; else echo 'lint:yaml: yamllint not installed, skipping (CI enforces it; install with pip install yamllint or brew install yamllint)'; fi", + "lint:yaml": "if command -v yamllint >/dev/null 2>&1; then git ls-files -z '*.yml' '*.yaml' | xargs -0 yamllint -s; elif [ -n \"$CI\" ]; then echo 'lint:yaml: yamllint is missing on this runner; the job needs ./.github/actions/setup with yamllint: \"true\"' >&2; exit 1; else echo 'lint:yaml: yamllint not installed, skipping (CI runs it; install with pip install yamllint or brew install yamllint)'; fi", "lint:fix": "bun x biome check --write .", "lint:arch": "bun .github/scripts/arch-lint.ts", "lint:package": "bun x publint --strict && bun x attw --pack . --profile esm-only", diff --git a/test/docs/checks-workflow.test.ts b/test/docs/checks-workflow.test.ts index 6789d60f..3010379e 100644 --- a/test/docs/checks-workflow.test.ts +++ b/test/docs/checks-workflow.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { readdirSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import { @@ -9,9 +9,21 @@ import { } from "../../.github/scripts/changed-sections.js"; import { RELEASE_PR_BRANCH_PREFIX } from "../../.github/scripts/release-pipeline.js"; import { headRefPrefixes, headRefPrefixesIn } from "./head-ref.js"; +import { + type CompositeAction, + executedLines, + installs, + ROOT, + readAction, + readWorkflow, + SETUP_USES, + type Step, + setupInstalls, + type Workflow, + workflowFiles, + workflowText, +} from "./workflow-loader.js"; -const ROOT = join(import.meta.dir, "..", ".."); -const WORKFLOWS_DIR = join(ROOT, ".github", "workflows"); const COMPOSITE_DIR = ".github/actions/fetch-test-artifacts"; const COMPOSITE_USES = `./${COMPOSITE_DIR}`; const PATHS_TS = "test/e2e/openapi/paths.ts"; @@ -125,22 +137,6 @@ const LOADERS: readonly Loader[] = [ ]; const [SUITE, HARNESS] = LOADERS as [Loader, Loader]; -interface Step { - id?: string; - uses?: string; - run?: string; - shell?: string; - if?: string; - "continue-on-error"?: boolean; - with?: Record; -} -interface Workflow { - jobs: Record; -} -interface CompositeAction { - runs: { using?: string; steps?: Step[] }; -} - /** True when a cache `path` entry (a file, a directory, or a glob) takes in `file`. */ function pathEntryCovers(entry: string, file: string): boolean { return ( @@ -174,16 +170,6 @@ function cacheKeyOf(step: Step, path: string): string { return key as string; } -function readWorkflow(file: string): Workflow { - return parseYaml(readFileSync(join(WORKFLOWS_DIR, file), "utf8")) as Workflow; -} - -function readComposite(): CompositeAction { - return parseYaml( - readFileSync(join(ROOT, COMPOSITE_DIR, "action.yml"), "utf8"), - ) as CompositeAction; -} - /** The quoted file patterns inside the key's hashFiles(...) call. */ function hashFilesPatterns(key: string): string[] { const match = key.match(/hashFiles\(([^)]*)\)/); @@ -306,33 +292,6 @@ function supports(step: Step, provider: Step): boolean { ); } -/** The run scalar's lines with every heredoc body (`< /^\s*bun install(?:\s|$)/.test(line) && !line.includes("||"), - ); -} - function runsFetch(run: string | undefined, fetchScript: string): boolean { const token = new RegExp(`(?:^|[\\s!(;&|])bun ${escapeRegExp(fetchScript)}(?=[\\s;)&|]|$)`); return executedLines(run ?? "").some((line) => !line.trim().startsWith("#") && token.test(line)); @@ -383,13 +342,13 @@ function expectArtifactsProvided(where: string, steps: Step[]): void { const provider = steps[providerIdx] as Step; const before = steps.slice(0, providerIdx); expect( - before.some( - (step) => (step.uses ?? "").startsWith("oven-sh/setup-bun@") && supports(step, provider), - ), - `${where}: a reliable oven-sh/setup-bun must precede the ${artifact.label} provider (its fetch runs under bun)`, + before.some((step) => step.uses === SETUP_USES && supports(step, provider)), + `${where}: a reliable ${SETUP_USES} must precede the ${artifact.label} provider (its fetch runs under bun)`, ).toBe(true); expect( - before.some((step) => installs(step.run) && supports(step, provider)), + before.some( + (step) => (installs(step.run) || setupInstalls(step)) && supports(step, provider), + ), `${where}: a reliable bun install must precede the ${artifact.label} provider (its fetch imports installed packages)`, ).toBe(true); for (const [loader, loaderIdx] of consumers) { @@ -448,8 +407,8 @@ function expectKeyPinned(key: string, artifact: FetchedArtifact): void { } describe("the fetch-test-artifacts composite", () => { - const action = readComposite(); - const steps = () => readComposite().runs.steps ?? []; + const action = readAction(COMPOSITE_DIR); + const steps = () => readAction(COMPOSITE_DIR).runs.steps ?? []; const cacheOf = (artifact: FetchedArtifact) => steps().find((step) => cachesArtifact(step, artifact.path)) as Step; const keyOf = (artifact: FetchedArtifact) => cacheKeyOf(cacheOf(artifact), artifact.path); @@ -641,7 +600,7 @@ describe("the fetch-test-artifacts composite", () => { }); describe("fetched test artifacts across workflows", () => { - const files = readdirSync(WORKFLOWS_DIR).filter((f) => /\.ya?ml$/.test(f)); + const files = workflowFiles(); const workflows = files.map((file) => ({ file, wf: readWorkflow(file) })); // Pinned: a derivation that silently found nothing would pass every guard below vacuously. @@ -699,9 +658,11 @@ describe("fetched test artifacts across workflows", () => { }); const CANARY = "nightly.yml#float-canary"; + const CHECK = "checks.yml#check"; const E2E_NIGHTLY = "e2e-nightly.yml#nightly"; const COVERAGE = "checks.yml#endpoint-coverage"; const canary = () => readWorkflow("nightly.yml").jobs["float-canary"]?.steps ?? []; + const check = () => readWorkflow("checks.yml").jobs.check?.steps ?? []; const coverage = () => readWorkflow("checks.yml").jobs["endpoint-coverage"]?.steps ?? []; const ungated = ({ if: _, ...step }: Step): Step => step; const e2eNightly = () => readWorkflow("e2e-nightly.yml").jobs.nightly?.steps ?? []; @@ -714,8 +675,8 @@ describe("fetched test artifacts across workflows", () => { const isInstall = (step: Step) => installs(step.run); const onInstall = (steps: Step[], patch: (step: Step) => Step) => steps.map((step) => (isInstall(step) ? patch(step) : step)); - const onSetupBun = (steps: Step[], patch: (step: Step) => Step) => - steps.map((step) => ((step.uses ?? "").startsWith("oven-sh/setup-bun@") ? patch(step) : step)); + const onSetup = (steps: Step[], patch: (step: Step) => Step) => + steps.map((step) => (step.uses === SETUP_USES ? patch(step) : step)); const moved = (steps: Step[], matches: (step: Step) => boolean, to: "first" | "last") => { const [step] = steps.splice(steps.findIndex(matches), 1); return to === "first" ? [step as Step, ...steps] : [...steps, step as Step]; @@ -781,16 +742,16 @@ describe("fetched test artifacts across workflows", () => { /nightly\.yml#float-canary fetches the GraphQL schema directly; a cached loading job restores it through/, ], [ - "a composite step before setup-bun", + "a composite step before the setup", CANARY, () => moved(canary(), isComposite, "first"), - /reliable oven-sh\/setup-bun must precede the trimmed OpenAPI spec provider/, + /reliable \.\/\.github\/actions\/setup must precede the trimmed OpenAPI spec provider/, ], [ - "a setup-bun allowed to fail", + "a setup allowed to fail", CANARY, - () => onSetupBun(canary(), (step) => ({ ...step, "continue-on-error": true })), - /reliable oven-sh\/setup-bun must precede/, + () => onSetup(canary(), (step) => ({ ...step, "continue-on-error": true })), + /reliable \.\/\.github\/actions\/setup must precede/, ], [ "a composite step before the install", @@ -798,6 +759,22 @@ describe("fetched test artifacts across workflows", () => { () => moved(canary(), isInstall, "last"), /reliable bun install must precede the trimmed OpenAPI spec provider/, ], + [ + "a setup whose install is switched off where no run step installs", + CHECK, + () => onSetup(check(), (step) => ({ ...step, with: { ...step.with, install: "false" } })), + /checks\.yml#check: a reliable bun install must precede the trimmed OpenAPI spec provider/, + ], + [ + "a setup whose install is an expression the pin cannot read", + CHECK, + () => + onSetup(check(), (step) => ({ + ...step, + with: { ...step.with, install: `\${{ 'true' }}` }, + })), + /checks\.yml#check: a reliable bun install must precede the trimmed OpenAPI spec provider/, + ], [ "a duplicated composite step", CANARY, @@ -913,7 +890,7 @@ function expectReleasePrefixes(wf: Workflow): void { } describe("checks.yml release PR branch spelling", () => { - const text = readFileSync(join(WORKFLOWS_DIR, "checks.yml"), "utf8"); + const text = workflowText("checks.yml"); // Workflows cannot import the constant, so the head_ref conditions spell it by hand; a drifted spelling skips the anchor-check on every release PR // instead of failing there. diff --git a/test/docs/e2e-nightly-workflow.test.ts b/test/docs/e2e-nightly-workflow.test.ts index 1a09fdcf..66634223 100644 --- a/test/docs/e2e-nightly-workflow.test.ts +++ b/test/docs/e2e-nightly-workflow.test.ts @@ -4,39 +4,17 @@ */ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { parse as parseYaml } from "yaml"; +import { readWorkflow } from "./workflow-loader.js"; -const ROOT = join(import.meta.dir, "..", ".."); const FUZZ_ISSUE_ACTION = "Vivswan/repo-platform/actions/fuzz-issue@stable"; -interface Step { - name?: string; - id?: string; - run?: string; - uses?: string; - if?: string; - env?: Record; - with?: Record; -} -interface Workflow { - on?: Record; - permissions?: Record; - jobs: Record }>; -} - -function workflow(file: string): Workflow { - return parseYaml(readFileSync(join(ROOT, ".github", "workflows", file), "utf8")) as Workflow; -} - describe.each([ // The run_attempt suffix: upload-artifact refuses a duplicate name, so a re-run attempt would upload nothing // and the filed issue would point at an artifact that never existed. ["e2e-nightly.yml", "nightly", "e2e-fuzz", `e2e-artifacts-\${{ github.run_attempt }}`], ["nightly-fuzz.yml", "fuzz", "fuzz-nightly", `fuzz-failures-\${{ github.run_attempt }}`], ])("%s issue + auto-assign path", (file, job, label, artifactName) => { - const wf = workflow(file); + const wf = readWorkflow(file); const steps = wf.jobs[job]?.steps ?? []; const filers = steps.filter((s) => s.uses === FUZZ_ISSUE_ACTION); @@ -100,16 +78,11 @@ describe.each([ }); describe("auto-assign.yml caller forwards the dispatched issue", () => { - const wf = parseYaml( - readFileSync(join(ROOT, ".github", "workflows", "auto-assign.yml"), "utf8"), - ) as Workflow; + const wf = readWorkflow("auto-assign.yml"); test("workflow_dispatch declares issue as an optional input and the reusable call forwards it", () => { - const dispatch = wf.on?.workflow_dispatch as - | { inputs?: Record } - | undefined; // The nightly filer always passes a number, and a bare dispatch must still run the full sweep. - expect(dispatch?.inputs?.issue).toMatchObject({ required: false, default: "" }); + expect(wf.on.workflow_dispatch?.inputs?.issue).toMatchObject({ required: false, default: "" }); expect(String(wf.jobs["auto-assign"]?.with?.issue)).toContain("inputs.issue"); }); }); diff --git a/test/docs/npm-publish-workflows.test.ts b/test/docs/npm-publish-workflows.test.ts index 1b73e3b2..d41dafb5 100644 --- a/test/docs/npm-publish-workflows.test.ts +++ b/test/docs/npm-publish-workflows.test.ts @@ -12,39 +12,21 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { parse as parseYaml } from "yaml"; +import { type Job, readWorkflow, type Step } from "./workflow-loader.js"; -const ROOT = join(import.meta.dir, "..", ".."); - -interface Step { - name?: string; - id?: string; - uses?: string; - if?: string; - run?: string; - env?: Record; - with?: Record; -} -interface Job { - if?: string; - needs?: string[]; - concurrency?: { group?: string; queue?: string; "cancel-in-progress"?: boolean }; - permissions?: Record; - steps: Step[]; -} -interface Workflow { - jobs: Record; -} - -function workflow(file: string): Workflow { - return parseYaml(readFileSync(join(ROOT, ".github", "workflows", file), "utf8")) as Workflow; -} +/** A job that runs steps (the publishers are never reusable-workflow calls). */ +type RunJob = Job & { steps: Step[] }; function must(value: T | undefined, what: string): T { if (value === undefined) throw new Error(`no ${what}`); return value; } +function runJob(job: Job | undefined, what: string): RunJob { + const found = must(job, what); + return { ...found, steps: must(found.steps, `${what} steps`) }; +} + /** Every string a step hands the runner beyond its script: env values, with values, the gate. */ function stepInputs(step: Step): string[] { return [ @@ -54,12 +36,12 @@ function stepInputs(step: Step): string[] { ]; } -const stepNamed = (job: Job, name: string): Step => +const stepNamed = (job: RunJob, name: string): Step => must( job.steps.find((step) => step.name === name), `step ${name}`, ); -const setupNode = (job: Job): Step => +const setupNode = (job: RunJob): Step => must( job.steps.find((step) => step.uses?.startsWith("actions/setup-node@")), "setup-node step", @@ -117,7 +99,7 @@ const EXPECTED: Contract = { ], }; -function contractOf(next: Job, stable: Job): Contract { +function contractOf(next: RunJob, stable: RunJob): Contract { const probe = next.steps.findIndex((step) => step.id === "oidc"); if (probe < 0) throw new Error("publish-next has no oidc probe"); const publishLines = (step: Step): string[] => @@ -130,7 +112,7 @@ function contractOf(next: Job, stable: Job): Contract { nextPermissions: next.permissions, repositoryGuards: [next.if, stable.if], lanes: [next.concurrency, stable.concurrency], - stableNeeds: [...(stable.needs ?? [])].sort(), + stableNeeds: [stable.needs ?? []].flat().sort(), ungatedNextSteps: next.steps .slice(probe + 1) .filter((step) => step.if !== OIDC_GATE && step.if !== PUBLISHED_GATE) @@ -153,16 +135,14 @@ function contractOf(next: Job, stable: Job): Contract { } describe("the npm publish jobs", () => { - const postGreen = workflow("post-green.yml"); - const updateRelease = workflow("update-release.yml"); - const next = must(postGreen.jobs["publish-next"], "publish-next job"); - const stable = must(updateRelease.jobs["publish-npm"], "publish-npm job"); + const next = runJob(readWorkflow("post-green.yml").jobs["publish-next"], "publish-next job"); + const stable = runJob(readWorkflow("update-release.yml").jobs["publish-npm"], "publish-npm job"); test("both publish through OIDC alone, with the same guard and build, and publish-next skips whole without a token", () => { expect(contractOf(next, stable)).toEqual(EXPECTED); }); - const REGRESSIONS: Array<[string, (next: Job, stable: Job) => void, keyof Contract]> = [ + const REGRESSIONS: Array<[string, (next: RunJob, stable: RunJob) => void, keyof Contract]> = [ [ "a stable publish without the OIDC grant", (_next, stable) => (stable.permissions = { contents: "read" }), @@ -240,7 +220,7 @@ describe("the npm publish jobs", () => { "a library build that diverged between the jobs", (_next, stable) => { const build = stepNamed(stable, "Build the library"); - build.run = build.run?.replace("--ignore-scripts", ""); + build.run = build.run?.replace("build:lib", "build"); }, "sameBuild", ], @@ -321,7 +301,7 @@ function runStep( } describe("the OIDC probe under bash", () => { - const next = must(workflow("post-green.yml").jobs["publish-next"], "publish-next job"); + const next = runJob(readWorkflow("post-green.yml").jobs["publish-next"], "publish-next job"); const run = must(must(next.steps[0], "probe step").run, "probe run"); test("a runner that minted a token URL proceeds silently", () => { @@ -342,7 +322,7 @@ describe("the OIDC probe under bash", () => { }); describe("the npm floor guard under bash", () => { - const stable = must(workflow("update-release.yml").jobs["publish-npm"], "publish-npm job"); + const stable = runJob(readWorkflow("update-release.yml").jobs["publish-npm"], "publish-npm job"); const run = must( stepNamed(stable, "Require an npm that publishes through OIDC").run, "guard run", @@ -398,8 +378,8 @@ describe("the npm floor guard under bash", () => { }); describe("the publish blocks under bash", () => { - const next = must(workflow("post-green.yml").jobs["publish-next"], "publish-next job"); - const stable = must(workflow("update-release.yml").jobs["publish-npm"], "publish-npm job"); + const next = runJob(readWorkflow("post-green.yml").jobs["publish-next"], "publish-next job"); + const stable = runJob(readWorkflow("update-release.yml").jobs["publish-npm"], "publish-npm job"); const nextRun = must( stepNamed(next, "Publish the pre-release under the next dist-tag").run, "next publish run", diff --git a/test/docs/post-green-workflow.test.ts b/test/docs/post-green-workflow.test.ts index 8f23c726..21ab754a 100644 --- a/test/docs/post-green-workflow.test.ts +++ b/test/docs/post-green-workflow.test.ts @@ -6,42 +6,13 @@ import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { parse as parseYaml } from "yaml"; - -const ROOT = join(import.meta.dir, "..", ".."); - -interface Step { - name?: string; - id?: string; - uses?: string; - if?: string; - run?: string; - env?: Record; - with?: Record; -} -interface Input { - required?: boolean; - type?: string; - default?: unknown; -} -interface Trigger { - inputs?: Record; -} +import { readWorkflow, SETUP_USES, type Workflow } from "./workflow-loader.js"; function must(value: T | undefined, what: string): T { if (value === undefined) throw new Error(`post-green.yml has no ${what}`); return value; } -interface CallerJob { - uses?: string; - with?: Record; - secrets?: unknown; - permissions?: Record; - steps?: Step[]; - [key: string]: unknown; -} - interface PinnedStep { name: string | undefined; id: string | undefined; @@ -51,14 +22,6 @@ interface PinnedStep { env: Record | undefined; with: Record | undefined; } -interface CallTrigger extends Trigger { - secrets?: Record; -} -interface Caller { - on: Record & { workflow_call?: CallTrigger | null }; - jobs: Record; - [key: string]: unknown; -} interface CallerContract { /** The workflow's top-level keys: no lane, permissions, or env above the jobs. */ @@ -222,18 +185,18 @@ const CALLER_EXPECTED: CallerContract = { { name: undefined, id: undefined, - uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + uses: SETUP_USES, if: PROCEED, run: undefined, env: undefined, - with: { "bun-version-file": ".bun-version" }, + with: undefined, }, { name: "Build the bundle and the library", id: undefined, uses: undefined, if: PROCEED, - run: "bun install --frozen-lockfile --ignore-scripts\nbun run build:bundle\nbun run build:lib\n", + run: "bun run build:bundle\nbun run build:lib\n", env: undefined, with: undefined, }, @@ -280,11 +243,11 @@ const CALLER_EXPECTED: CallerContract = { { name: undefined, id: undefined, - uses: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + uses: SETUP_USES, if: OIDC_PROCEED, run: undefined, env: undefined, - with: { "bun-version-file": ".bun-version" }, + with: undefined, }, { name: undefined, @@ -309,7 +272,7 @@ const CALLER_EXPECTED: CallerContract = { id: undefined, uses: undefined, if: OIDC_PROCEED, - run: "bun install --frozen-lockfile --ignore-scripts\nbun run build:lib\n", + run: "bun run build:lib", env: undefined, with: undefined, }, @@ -336,7 +299,7 @@ const CALLER_EXPECTED: CallerContract = { ], }; -function callerContractOf(wf: Caller): CallerContract { +function callerContractOf(wf: Workflow): CallerContract { const call = wf.on.workflow_call; return { topLevel: Object.keys(wf).sort(), @@ -368,20 +331,18 @@ function callerContractOf(wf: Caller): CallerContract { }; } -function expectCallerContract(wf: Caller): void { +function expectCallerContract(wf: Workflow): void { expect(callerContractOf(wf)).toEqual(CALLER_EXPECTED); } describe("post-green.yml publishes the build branch", () => { - const wf = parseYaml( - readFileSync(join(ROOT, ".github", "workflows", "post-green.yml"), "utf8"), - ) as Caller; + const wf = readWorkflow("post-green.yml"); test("two self-contained jobs, each gated on its probe, with the judged sha as the only input", () => { expectCallerContract(wf); }); - const REGRESSIONS: Array<[string, (w: Caller) => void, keyof CallerContract]> = [ + const REGRESSIONS: Array<[string, (w: Workflow) => void, keyof CallerContract]> = [ [ "an undeclared job beside the publisher", (w) => (w.jobs.extra = { "runs-on": "ubuntu-latest", steps: [{ run: "echo" }] }), @@ -614,11 +575,7 @@ describe("post-green.yml publishes the build branch", () => { }), "secrets", ], - [ - "a push trigger of its own", - (w) => (w.on.push = { branches: ["main"] } as Trigger), - "triggers", - ], + ["a push trigger of its own", (w) => (w.on.push = { branches: ["main"] }), "triggers"], [ "a dispatch that could reach the publisher outside the gate", (w) => (w.on.workflow_dispatch = null), @@ -694,9 +651,7 @@ const STATIC_ERROR = const FENCE_OPEN = /^::stop-commands::([0-9a-f]{32})$/; describe("the push probe under bash", () => { - const wf = parseYaml( - readFileSync(join(ROOT, ".github", "workflows", "post-green.yml"), "utf8"), - ) as Caller; + const wf = readWorkflow("post-green.yml"); const run = must(must(must(wf.jobs.build, "build job").steps?.[1], "probe step").run, "run"); function expectFenced(lines: string[], inner: string[]): void { diff --git a/test/docs/repo-owned-workflows.test.ts b/test/docs/repo-owned-workflows.test.ts new file mode 100644 index 00000000..267ba8bd --- /dev/null +++ b/test/docs/repo-owned-workflows.test.ts @@ -0,0 +1,196 @@ +/** + * The invariants of the repo-owned workflows that no single file shows: every job sets bun up through the one + * composite (or runs no bun), every job is bounded, every action is pinned the same way everywhere, the commit-back + * push jobs run no PR code under their write token, and lint:yaml is real where a job asks for it. The managed + * workflows are the platform's and stay out of every pin here. + */ + +import { describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { isAlias, isMap, isScalar, parseDocument, visit } from "yaml"; +import { + type Job, + ROOT, + readAction, + readWorkflow, + repoOwnedWorkflowFiles, + SETUP_USES, + type Step, + setupYamllint, +} from "./workflow-loader.js"; + +const SCRIPTS = ( + JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + scripts: Record; + } +).scripts; +/** A bun invocation the shell would run: the word `bun` at a command position. */ +const RUNS_BUN = /(?:^|[\s;&|(])bun(?=\s|$)/m; +const isSetup = (step: Step) => step.uses === SETUP_USES; +/** A step that needs bun on the runner: a run step invoking it, or a local composite (each of ours runs bun). */ +const needsBun = (step: Step) => + RUNS_BUN.test(step.run ?? "") || (!isSetup(step) && (step.uses ?? "").startsWith("./")); + +/** Why a job's bun setup is wrong, or undefined: one composite step ahead of every bun user, or no bun at all. */ +function setupProblem(steps: Step[]): string | undefined { + const setups = steps.filter(isSetup); + if (steps.some((step) => (step.uses ?? "").startsWith("oven-sh/setup-bun@"))) + return "setup-bun outside the composite"; + if (setups.length === 0 && !steps.some(needsBun)) return undefined; + if (setups.length !== 1) return `${setups.length} composite steps for a bun job`; + const early = steps.slice(0, steps.indexOf(setups[0] as Step)).filter(needsBun); + return early.length > 0 + ? `bun runs before the composite: ${early.map((s) => s.name ?? s.uses ?? s.run).join("; ")}` + : undefined; +} + +/** `#` and the job, over every repo-owned workflow. */ +const repoOwnedJobs = (): Array<[string, Job]> => + repoOwnedWorkflowFiles().flatMap((file) => + Object.entries(readWorkflow(file).jobs).map(([id, job]): [string, Job] => [ + `${file}#${id}`, + job, + ]), + ); +const jobsWhere = (test: (step: Step) => boolean) => + repoOwnedJobs().flatMap(([where, job]) => ((job.steps ?? []).some(test) ? [where] : [])); + +describe("the repo-owned workflows", () => { + test("every job sets bun up through the composite once, or runs no bun; every job carries a timeout", () => { + for (const [where, job] of repoOwnedJobs()) { + expect(setupProblem(job.steps ?? []), where).toBeUndefined(); + expect(job["timeout-minutes"], `${where} has no timeout-minutes`).toBeGreaterThan(0); + } + // Controls: the derivation sees the composite, and each drift class it exists for is a problem. + expect(jobsWhere(isSetup).length).toBeGreaterThan(15); + const drifts: Step[][] = [ + [{ uses: SETUP_USES }, { uses: "oven-sh/setup-bun@0000" }], + [{ uses: "./.github/actions/fetch-test-artifacts" }], + [{ run: "bun --version" }, { uses: SETUP_USES }], + ]; + for (const steps of drifts) expect(setupProblem(steps), JSON.stringify(steps)).toBeDefined(); + }); + + test("no step inside the setup composite may mask its failure (a job relies on each one it asks for)", () => { + const steps = readAction(".github/actions/setup").runs.steps ?? []; + expect(steps.length).toBeGreaterThan(1); + for (const step of steps) + expect(step["continue-on-error"], step.run ?? step.uses).toBeUndefined(); + }); +}); + +/** Every `uses:` value in a YAML file with the comment on its line: block or flow style, an alias resolved. */ +function usesIn(text: string): Array<[string, string]> { + const doc = parseDocument(text); + const found: Array<[string, string]> = []; + visit(doc, { + Pair(_, pair, path) { + const key = isAlias(pair.key) ? pair.key.resolve(doc) : pair.key; + const written = pair.value; + if (!isScalar(key) || key.value !== "uses" || !(isScalar(written) || isAlias(written))) + return; + const node = isAlias(written) ? written.resolve(doc) : written; + // A flow step's comment (`- {uses: x} # v1`) sits on the enclosing map, not on the scalar. + const parent = path[path.length - 1]; + const comment = + written.comment ?? (isMap(parent) && parent.flow ? parent.comment : undefined) ?? ""; + if (isScalar(node)) found.push([String(node.value), comment.trim()]); + }, + }); + return found; +} + +const SHA_PIN = /^[\w.-]+\/[\w.-]+(?:\/[\w./-]+)?@[0-9a-f]{40}$/; +/** The fleet's own actions ride their stable channel by design; any other ref is a pin problem. */ +const FLEET_STABLE = /^Vivswan\/repo-platform\/actions\/[\w-]+@stable$/; + +function pinProblem(uses: string, comment: string): string | undefined { + if (uses.startsWith("./")) + return comment === "" ? undefined : "a local action carries no version comment"; + if (FLEET_STABLE.test(uses)) return undefined; + if (!SHA_PIN.test(uses)) return "not a full-sha pin"; + return /^v\d+(?:\.\d+)*$/.test(comment) ? undefined : "no version comment after the sha"; +} + +describe("action pins across the repo-owned files", () => { + const files = [ + ...repoOwnedWorkflowFiles().map((file) => `.github/workflows/${file}`), + ...readdirSync(join(ROOT, ".github/actions")).map( + (name) => `.github/actions/${name}/action.yml`, + ), + ]; + const lines = files.flatMap((file) => + usesIn(readFileSync(join(ROOT, file), "utf8")).map(([uses, comment]) => ({ + file, + uses, + comment, + })), + ); + + test("every uses: is a local path, the fleet action at @stable, or a full sha with its version comment; one sha per action", () => { + expect(lines.length).toBeGreaterThan(30); + const problems = lines.flatMap(({ file, uses, comment }) => { + const problem = pinProblem(uses, comment); + return problem ? [`${file}: ${uses} (${problem})`] : []; + }); + expect(problems).toEqual([]); + expect(pinProblem("Vivswan/repo-platform/actions/fuzz-issue@build", "")).toBeDefined(); + const pins = new Map>(); + for (const { uses, comment } of lines.filter((line) => SHA_PIN.test(line.uses))) { + const [action, sha] = uses.split("@") as [string, string]; + pins.set(action, (pins.get(action) ?? new Set()).add(`${sha} ${comment}`)); + } + expect(pins.size).toBeGreaterThan(3); + for (const [action, shas] of pins) + expect([...shas], `${action} is pinned ${shas.size} ways`).toHaveLength(1); + }); +}); + +describe("the commit-back push jobs", () => { + test.each([ + ["auto-fix.yml", "Commit and push the fix"], + ["auto-format.yml", "Commit and push the formatting"], + ])( + "%s: no PR code runs under the write token, and the push is leased to the patched head", + (file, name) => { + const push = readWorkflow(file).jobs.push; + expect(push?.permissions?.contents).toBe("write"); + expect((push?.steps ?? []).filter(needsBun)).toEqual([]); + const step = push?.steps?.find((candidate) => candidate.name === name); + expect(step?.env?.HEAD_SHA).toBeDefined(); + expect(step?.run).toContain(`--force-with-lease="refs/heads/\${HEAD_REF}:\${HEAD_SHA}"`); + }, + ); +}); + +describe("lint:yaml", () => { + test("the composite hands yamllint to exactly the jobs that run it", () => { + const running = jobsWhere((step) => + /\bbun run (?:check|lint:yaml)(?![\w:.-])/.test(step.run ?? ""), + ); + expect(running.length).toBeGreaterThan(0); + expect(jobsWhere(setupYamllint)).toEqual(running); + expect(SCRIPTS.check).toContain("bun run lint:yaml"); + }); + + /** The script's exit status from the repository root with PATH cut to the system directories, where yamllint is absent. */ + function lintYamlWithout(env: Record): number { + try { + execFileSync("bash", ["-c", SCRIPTS["lint:yaml"] ?? ""], { + cwd: ROOT, + stdio: "pipe", + env: { HOME: process.env.HOME ?? "", ...env, PATH: "/usr/bin:/bin" }, + }); + return 0; + } catch (error) { + return (error as { status?: number }).status ?? -1; + } + } + + test("without yamllint a CI run fails and a local run skips", () => { + expect(lintYamlWithout({ CI: "true" })).toBe(1); + expect(lintYamlWithout({})).toBe(0); + }); +}); diff --git a/test/docs/workflow-loader.ts b/test/docs/workflow-loader.ts new file mode 100644 index 00000000..1d299903 --- /dev/null +++ b/test/docs/workflow-loader.ts @@ -0,0 +1,128 @@ +/** + * One reader for the workflow and composite-action YAML the docs tests pin: the file list split by header, the parsed + * shapes, and the step predicates around the setup composite every repo-owned job goes through. + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; + +export const ROOT = join(import.meta.dir, "..", ".."); +const WORKFLOWS_DIR = join(ROOT, ".github", "workflows"); +/** The setup composite (bun, the locked install, yamllint on request), as a job's `uses:` spells it. */ +export const SETUP_USES = "./.github/actions/setup"; +/** A leading comment block naming the platform sync: such a file is overwritten on every sync, every other is repo-owned. */ +const MANAGED = /^(?:\s*#.*\n)*?\s*#.*managed by Vivswan\/repo-platform/; + +export interface Step { + name?: string; + id?: string; + uses?: string; + run?: string; + shell?: string; + if?: string; + "continue-on-error"?: boolean; + env?: Record; + with?: Record; +} +interface Concurrency { + group?: string; + queue?: string; + "cancel-in-progress"?: boolean | string; +} +/** A job: steps on a runner, or (`uses`) a call of a reusable workflow. */ +export interface Job { + if?: string; + needs?: string | string[]; + "runs-on"?: string; + "timeout-minutes"?: number; + permissions?: Record; + concurrency?: Concurrency; + steps?: Step[]; + uses?: string; + with?: Record; + secrets?: unknown; +} +interface Trigger { + inputs?: Record; + secrets?: Record; + [key: string]: unknown; +} +export interface Workflow { + on: Record; + permissions?: Record; + concurrency?: Concurrency; + jobs: Record; +} +export interface CompositeAction { + runs: { using?: string; steps?: Step[] }; +} + +export function workflowText(file: string): string { + return readFileSync(join(WORKFLOWS_DIR, file), "utf8"); +} + +export function readWorkflow(file: string): Workflow { + return parseYaml(workflowText(file)) as Workflow; +} + +/** `dir` is relative to the repository root, e.g. `.github/actions/setup`. */ +export function readAction(dir: string): CompositeAction { + return parseYaml(readFileSync(join(ROOT, dir, "action.yml"), "utf8")) as CompositeAction; +} + +export function workflowFiles(): string[] { + return readdirSync(WORKFLOWS_DIR) + .filter((file) => /\.ya?ml$/.test(file)) + .sort(); +} + +export function repoOwnedWorkflowFiles(): string[] { + return workflowFiles().filter((file) => !MANAGED.test(workflowText(file))); +} + +/** The run scalar's lines with every heredoc body (`< /^\s*bun install(?:\s|$)/.test(line) && !line.includes("||"), + ); +} + +/** + * A setup input as the runner compares it: lower-cased, since GitHub compares expression strings without regard to + * letter case. An expression (`${{ ... }}`) has no value here, so it is undefined and satisfies neither predicate. + */ +function setupInput(step: Step, name: string, fallback: string): string | undefined { + const raw = String(step.with?.[name] ?? fallback); + return raw.includes("${{") ? undefined : raw.toLowerCase(); +} + +/** The setup composite step when it installs: any literal `install` input but "false" does. */ +export function setupInstalls(step: Step): boolean { + const install = setupInput(step, "install", "true"); + return step.uses === SETUP_USES && install !== undefined && install !== "false"; +} + +export function setupYamllint(step: Step): boolean { + return step.uses === SETUP_USES && setupInput(step, "yamllint", "false") === "true"; +}