diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3177d13..4253dfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,9 @@ on: permissions: contents: read +env: + SHELL: /bin/bash + concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -20,17 +23,20 @@ jobs: test_self_hosted_trusted: name: test if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public timeout-minutes: 15 steps: - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6 - with: - version: 10.32.1 - uses: actions/setup-node@v6 with: node-version: "24" - cache: pnpm + - uses: pnpm/action-setup@v6 + with: + version: 10.32.1 - run: pnpm install --frozen-lockfile - run: pnpm lint - run: pnpm test:coverage @@ -108,17 +114,20 @@ jobs: linux_docker_contract_trusted: name: linux docker contract if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public timeout-minutes: 20 steps: - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6 - with: - version: 10.32.1 - uses: actions/setup-node@v6 with: node-version: "24" - cache: pnpm + - uses: pnpm/action-setup@v6 + with: + version: 10.32.1 - run: pnpm install --frozen-lockfile - name: Render Linux Docker runner manifests run: | @@ -140,13 +149,12 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6 - with: - version: 10.32.1 - uses: actions/setup-node@v6 with: node-version: "24" - cache: pnpm + - uses: pnpm/action-setup@v6 + with: + version: 10.32.1 - run: pnpm install --frozen-lockfile - name: Render Windows Docker runner manifests shell: pwsh @@ -165,17 +173,20 @@ jobs: lume_macos_contract_trusted: name: lume macos contract if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: macos-latest + runs-on: + - self-hosted + - macOS + - ARM64 + - xcode timeout-minutes: 20 steps: - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6 - with: - version: 10.32.1 - uses: actions/setup-node@v6 with: node-version: "24" - cache: pnpm + - uses: pnpm/action-setup@v6 + with: + version: 10.32.1 - run: pnpm install --frozen-lockfile - run: pnpm lint - run: pnpm test @@ -208,13 +219,12 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6 - with: - version: 10.32.1 - uses: actions/setup-node@v6 with: node-version: "24" - cache: pnpm + - uses: pnpm/action-setup@v6 + with: + version: 10.32.1 - run: pnpm install --frozen-lockfile - run: pnpm lint - run: pnpm test diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 4355b52..c07192b 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -29,7 +29,11 @@ jobs: (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public timeout-minutes: 30 permissions: contents: write diff --git a/.github/workflows/pr-fast-ci.yml b/.github/workflows/pr-fast-ci.yml index d2256a0..1b0be4e 100644 --- a/.github/workflows/pr-fast-ci.yml +++ b/.github/workflows/pr-fast-ci.yml @@ -15,6 +15,7 @@ permissions: env: NODE_VERSION: '20' PYTHON_VERSION: '3.12' + SHELL: /bin/bash defaults: run: @@ -23,7 +24,11 @@ defaults: jobs: changes: name: Detect Relevant Changes - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public outputs: app: ${{ steps.filter.outputs.app }} ci: ${{ steps.filter.outputs.ci }} @@ -57,7 +62,11 @@ jobs: fast-checks: name: Fast Checks - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public timeout-minutes: 15 needs: changes if: >- @@ -69,21 +78,24 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} - - uses: pnpm/action-setup@v6 - with: - version: 10.32.1 - - uses: actions/setup-node@v6 with: node-version: "24" - cache: pnpm + + - uses: pnpm/action-setup@v6 + with: + version: 10.32.1 - name: Run fast checks run: bash scripts/ci/run-fast-checks.sh validate-secrets: name: Validate Secrets - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public timeout-minutes: 10 if: >- github.event.pull_request.draft == false && @@ -110,14 +122,13 @@ jobs: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} - - uses: pnpm/action-setup@v6 - with: - version: 10.32.1 - - uses: actions/setup-node@v6 with: node-version: "24" - cache: pnpm + + - uses: pnpm/action-setup@v6 + with: + version: 10.32.1 - run: pnpm install --frozen-lockfile - run: pnpm lint @@ -141,7 +152,11 @@ jobs: ci-gate: name: CI Gate - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public if: always() needs: - changes diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml index e6e3a23..49f2324 100644 --- a/.github/workflows/release-image.yml +++ b/.github/workflows/release-image.yml @@ -22,10 +22,17 @@ concurrency: group: release-image-${{ github.ref }} cancel-in-progress: false +env: + SHELL: /bin/bash + jobs: publish_and_verify: name: publish-and-verify - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} SYNOLOGY_RUNNER_BASE_DIR: /volume1/docker/github-runner-fleet @@ -34,14 +41,13 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6 - with: - version: 10.32.1 - - uses: actions/setup-node@v6 with: node-version: "24" - cache: pnpm + + - uses: pnpm/action-setup@v6 + with: + version: 10.32.1 - run: pnpm install --frozen-lockfile @@ -59,6 +65,24 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Ensure envsubst is available + run: | + set -euo pipefail + if command -v envsubst >/dev/null 2>&1; then + exit 0 + fi + + if [[ "$(id -u)" == "0" ]] && command -v apt-get >/dev/null 2>&1; then + apt-get update + apt-get install -y --no-install-recommends gettext-base + elif command -v sudo >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y --no-install-recommends gettext-base + else + echo "envsubst is required by cosign-installer." >&2 + exit 1 + fi + - uses: sigstore/cosign-installer@v4.1.2 - id: release_meta diff --git a/.github/workflows/rg-ci.yml b/.github/workflows/rg-ci.yml index f3beb01..b550c6e 100644 --- a/.github/workflows/rg-ci.yml +++ b/.github/workflows/rg-ci.yml @@ -24,20 +24,26 @@ on: permissions: contents: read +env: + SHELL: /bin/bash + jobs: ci: name: rg-ci - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public timeout-minutes: 20 steps: - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.package-manager-version }} - uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} - cache: pnpm + - uses: pnpm/action-setup@v6 + with: + version: ${{ inputs.package-manager-version }} - run: pnpm install --frozen-lockfile - run: pnpm lint - run: pnpm test diff --git a/.github/workflows/rg-release.yml b/.github/workflows/rg-release.yml index cbe9e67..c6f88dd 100644 --- a/.github/workflows/rg-release.yml +++ b/.github/workflows/rg-release.yml @@ -29,7 +29,11 @@ permissions: jobs: release: name: rg-release - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public timeout-minutes: 45 steps: - uses: actions/checkout@v6 @@ -48,6 +52,23 @@ jobs: platforms: linux/amd64,linux/arm64 push: true tags: ${{ inputs.image-ref }} + - name: Ensure envsubst is available + run: | + set -euo pipefail + if command -v envsubst >/dev/null 2>&1; then + exit 0 + fi + + if [[ "$(id -u)" == "0" ]] && command -v apt-get >/dev/null 2>&1; then + apt-get update + apt-get install -y --no-install-recommends gettext-base + elif command -v sudo >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y --no-install-recommends gettext-base + else + echo "envsubst is required by cosign-installer." >&2 + exit 1 + fi - uses: sigstore/cosign-installer@v4.1.2 - run: cosign sign --yes ${{ inputs.image-ref }}@${{ steps.build.outputs.digest }} - uses: actions/attest-build-provenance@v3 diff --git a/.github/workflows/rg-security.yml b/.github/workflows/rg-security.yml index 9a92095..b627857 100644 --- a/.github/workflows/rg-security.yml +++ b/.github/workflows/rg-security.yml @@ -20,7 +20,11 @@ permissions: jobs: security: name: rg-security - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public timeout-minutes: 20 steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 0fcb4ff..6d9f509 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -14,7 +14,11 @@ permissions: jobs: scorecard: name: openssf-scorecard - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public permissions: contents: read id-token: write diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 97e7899..c0f1e66 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -15,10 +15,17 @@ permissions: pull-requests: read security-events: write +env: + OSV_SCANNER_VERSION: v2.3.8 + jobs: codeql: name: codeql - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public timeout-minutes: 20 steps: - uses: actions/checkout@v6 @@ -30,7 +37,11 @@ jobs: dependency_review: name: dependency-review if: github.event_name == 'pull_request' - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public timeout-minutes: 10 steps: - uses: actions/checkout@v6 @@ -41,17 +52,40 @@ jobs: osv: name: osv - runs-on: ubuntu-latest + runs-on: + - self-hosted + - linux + - shell-only + - public timeout-minutes: 10 steps: - uses: actions/checkout@v6 - - uses: google/osv-scanner-action/osv-scanner-action@v2.3.8 + - name: Install OSV Scanner + run: | + set -euo pipefail + case "$(uname -m)" in + x86_64) osv_arch="amd64" ;; + aarch64|arm64) osv_arch="arm64" ;; + *) + echo "Unsupported OSV Scanner architecture: $(uname -m)" >&2 + exit 1 + ;; + esac + + osv_asset="osv-scanner_linux_${osv_arch}" + curl -fsSLO "https://github.com/google/osv-scanner/releases/download/${OSV_SCANNER_VERSION}/${osv_asset}" + curl -fsSLO "https://github.com/google/osv-scanner/releases/download/${OSV_SCANNER_VERSION}/osv-scanner_SHA256SUMS" + grep " ${osv_asset}$" osv-scanner_SHA256SUMS | sha256sum -c - + chmod +x "${osv_asset}" + mkdir -p "${RUNNER_TEMP}/osv-scanner" + mv "${osv_asset}" "${RUNNER_TEMP}/osv-scanner/osv-scanner" + echo "${RUNNER_TEMP}/osv-scanner" >> "${GITHUB_PATH}" + - name: Run OSV Scanner continue-on-error: true - with: - scan-args: |- - --lockfile=pnpm-lock.yaml - --format=sarif - --output=osv-results.sarif + run: osv-scanner scan -L pnpm-lock.yaml --format=sarif > osv-results.sarif + - name: Require OSV SARIF output + if: always() + run: test -s osv-results.sarif - uses: github/codeql-action/upload-sarif@v4 if: always() with: diff --git a/docker/Dockerfile b/docker/Dockerfile index 909a9e2..6b36afb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -8,6 +8,7 @@ ARG NODE_VERSION=18.20.8 ARG TERRAFORM_VERSION=1.6.6 ENV DEBIAN_FRONTEND=noninteractive \ + SHELL=/bin/bash \ RUNNER_SOURCE_HOME=/actions-runner \ RUNNER_TEMP=/tmp/github-runner-temp \ RUNNER_TOOL_CACHE=/opt/hostedtoolcache \ @@ -18,7 +19,7 @@ RUN apt-get update \ bash \ ca-certificates \ curl \ - docker.io \ + gettext-base \ git \ gosu \ jq \ @@ -30,6 +31,14 @@ RUN apt-get update \ zstd \ && rm -rf /var/lib/apt/lists/* +RUN install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends docker-ce-cli \ + && rm -rf /var/lib/apt/lists/* + RUN useradd --create-home --home-dir /home/runner --shell /bin/bash runner \ && mkdir -p "${RUNNER_TOOL_CACHE}" "${RUNNER_TEMP}" /opt/node18 \ && chown -R runner:runner /home/runner "${RUNNER_TOOL_CACHE}" "${RUNNER_TEMP}" /opt/node18 diff --git a/test/release-workflow.test.ts b/test/release-workflow.test.ts index 1dfb0dc..0c8ed49 100644 --- a/test/release-workflow.test.ts +++ b/test/release-workflow.test.ts @@ -3,8 +3,10 @@ import path from "node:path"; import YAML from "yaml"; import { describe, expect, test } from "vitest"; +const shellSafePublicRunner = ["self-hosted", "linux", "shell-only", "public"]; + describe("release workflow", () => { - test("publishes on hosted runners, verifies the pushed tag, and can create a repo release from main", () => { + test("publishes on shell-safe self-hosted runners, verifies the pushed tag, and can create a repo release from main", () => { const workflow = YAML.parse( fs.readFileSync( path.resolve(".github/workflows/release-image.yml"), @@ -16,7 +18,7 @@ describe("release workflow", () => { jobs: Record< string, { - "runs-on": string; + "runs-on": string | string[]; env: Record; steps: Array>; } @@ -39,7 +41,7 @@ describe("release workflow", () => { "id-token": "write", attestations: "write" }); - expect(job["runs-on"]).toBe("ubuntu-latest"); + expect(job["runs-on"]).toEqual(shellSafePublicRunner); expect(job.env).toMatchObject({ GITHUB_PAT: "${{ secrets.GITHUB_TOKEN }}", SYNOLOGY_RUNNER_BASE_DIR: "/volume1/docker/github-runner-fleet" diff --git a/test/security-workflow.test.ts b/test/security-workflow.test.ts index 8805312..de1c6cf 100644 --- a/test/security-workflow.test.ts +++ b/test/security-workflow.test.ts @@ -3,8 +3,10 @@ import path from "node:path"; import YAML from "yaml"; import { describe, expect, test } from "vitest"; +const shellSafePublicRunner = ["self-hosted", "linux", "shell-only", "public"]; + describe("security and reusable workflows", () => { - test("keeps security scans on hosted runners with Security tab upload", () => { + test("keeps security scans on shell-safe self-hosted runners with Security tab upload", () => { const workflow = YAML.parse( fs.readFileSync(path.resolve(".github/workflows/security.yml"), "utf8") ) as { permissions: Record; jobs: Record> }; @@ -14,16 +16,16 @@ describe("security and reusable workflows", () => { "security-events": "write" }); for (const job of Object.values(workflow.jobs)) { - expect(job["runs-on"]).toBe("ubuntu-latest"); + expect(job["runs-on"]).toEqual(shellSafePublicRunner); } expect(String(JSON.stringify(workflow))).toContain("github/codeql-action/init"); expect(String(JSON.stringify(workflow))).toContain("dependency-review-action"); - expect(String(JSON.stringify(workflow))).toContain("osv-scanner-action"); + expect(String(JSON.stringify(workflow))).toContain("osv-scanner/releases/download"); expect(String(JSON.stringify(workflow))).toContain("upload-sarif"); expect(workflow.jobs.osv.steps).toEqual( expect.arrayContaining([ expect.objectContaining({ - uses: "google/osv-scanner-action/osv-scanner-action@v2.3.8", + name: "Run OSV Scanner", "continue-on-error": true }) ]) @@ -37,11 +39,11 @@ describe("security and reusable workflows", () => { expect(workflow.on).not.toHaveProperty("pull_request"); expect(workflow.permissions).toEqual({ contents: "read" }); - expect(workflow.jobs.scorecard["runs-on"]).toBe("ubuntu-latest"); expect(workflow.jobs.scorecard.permissions).toMatchObject({ "id-token": "write", "security-events": "write" }); + expect(workflow.jobs.scorecard["runs-on"]).toEqual(shellSafePublicRunner); expect(String(JSON.stringify(workflow))).toContain( "ossf/scorecard-action@v2.4.3" ); @@ -56,7 +58,7 @@ describe("security and reusable workflows", () => { expect(workflow.on).toHaveProperty("workflow_call"); for (const job of Object.values(workflow.jobs)) { - expect(job["runs-on"]).toBe("ubuntu-latest"); + expect(job["runs-on"]).toEqual(shellSafePublicRunner); } } }); diff --git a/test/workflow.test.ts b/test/workflow.test.ts index 8499f8a..d4fa439 100644 --- a/test/workflow.test.ts +++ b/test/workflow.test.ts @@ -3,6 +3,9 @@ import path from "node:path"; import YAML from "yaml"; import { describe, expect, test } from "vitest"; +const shellSafePublicRunner = ["self-hosted", "linux", "shell-only", "public"]; +const macosXcodeRunner = ["self-hosted", "macOS", "ARM64", "xcode"]; + describe("CI workflow", () => { test("runs mutation testing in extended validation and uploads the report", () => { const workflow = YAML.parse( @@ -92,7 +95,7 @@ describe("CI workflow", () => { expect(gateJob.needs).toContain("drift-detect"); }); - test("keeps required trusted CI on hosted runners when shell-safe capacity is absent", () => { + test("keeps required trusted CI on shell-safe self-hosted runners", () => { const workflow = YAML.parse( fs.readFileSync(path.resolve(".github/workflows/ci.yml"), "utf8") ) as { @@ -112,18 +115,19 @@ describe("CI workflow", () => { (step) => step.uses === "actions/setup-node@v6" ); - expect(trustedJob["runs-on"]).toBe("ubuntu-latest"); + expect(trustedJob["runs-on"]).toEqual(shellSafePublicRunner); expect(pnpmStep?.with).toMatchObject({ version: "10.32.1" }); expect(setupNodeStep?.with).toMatchObject({ - "node-version": "24", - cache: "pnpm" + "node-version": "24" }); expect(forkSetupNodeStep?.with).toMatchObject({ - "node-version": "24", - cache: "pnpm" + "node-version": "24" }); + expect(steps.indexOf(setupNodeStep ?? {})).toBeLessThan( + steps.indexOf(pnpmStep ?? {}) + ); }); test("verifies the broader shell-safe toolchain contract on self-hosted runners", () => { @@ -186,7 +190,7 @@ describe("CI workflow", () => { expect(workflow.jobs.test_public_fork_pr["runs-on"]).toBe("ubuntu-latest"); }); - test("keeps PR fast checks on hosted runners and gates same-repo and fork paths", () => { + test("keeps PR fast checks on self-hosted runners and gates same-repo and fork paths", () => { const workflow = YAML.parse( fs.readFileSync(path.resolve(".github/workflows/pr-fast-ci.yml"), "utf8") ) as { @@ -198,7 +202,7 @@ describe("CI workflow", () => { workflow.jobs["validate-secrets"] ]; for (const job of selfHostedJobs) { - expect(job["runs-on"]).toBe("ubuntu-latest"); + expect(job["runs-on"]).toEqual(shellSafePublicRunner); expect(String(job.if)).toContain( "github.event.pull_request.head.repo.full_name == github.repository" ); @@ -209,7 +213,7 @@ describe("CI workflow", () => { ) ).toBe(true); - expect(workflow.jobs.changes["runs-on"]).toBe("ubuntu-latest"); + expect(workflow.jobs.changes["runs-on"]).toEqual(shellSafePublicRunner); expect(workflow.jobs["hosted-fork-fast-checks"]["runs-on"]).toBe( "ubuntu-latest" ); @@ -230,10 +234,10 @@ describe("CI workflow", () => { "hosted-fork-validate-secrets" ]) ); - expect(workflow.jobs["ci-gate"]["runs-on"]).toBe("ubuntu-latest"); + expect(workflow.jobs["ci-gate"]["runs-on"]).toEqual(shellSafePublicRunner); }); - test("renders the Linux Docker contract on hosted Linux before operators provision the pool", () => { + test("renders the Linux Docker contract on shell-safe self-hosted Linux", () => { const workflow = YAML.parse( fs.readFileSync(path.resolve(".github/workflows/ci.yml"), "utf8") ) as { @@ -246,7 +250,7 @@ describe("CI workflow", () => { (step) => step.name === "Render Linux Docker runner manifests" ); - expect(dockerJob["runs-on"]).toBe("ubuntu-latest"); + expect(dockerJob["runs-on"]).toEqual(shellSafePublicRunner); expect(String(renderStep?.run)).toContain("pnpm validate-linux-docker-config"); expect(String(renderStep?.run)).toContain("pnpm render-linux-docker-compose"); expect(String(renderStep?.run)).toContain( @@ -287,7 +291,7 @@ describe("CI workflow", () => { expect(String(syntaxStep?.run)).toContain("docker/runner-entrypoint.ps1"); }); - test("keeps the Lume macOS pool contract on hosted macOS runners", () => { + test("keeps the Lume macOS pool contract on self-hosted macOS runners", () => { const workflow = YAML.parse( fs.readFileSync(path.resolve(".github/workflows/ci.yml"), "utf8") ) as { @@ -306,7 +310,7 @@ describe("CI workflow", () => { (step) => step.name === "Validate Lume shell scripts" ); - expect(lumeJob["runs-on"]).toBe("macos-latest"); + expect(lumeJob["runs-on"]).toEqual(macosXcodeRunner); expect(String(renderStep?.run)).toContain("pnpm validate-lume-config"); expect(String(renderStep?.run)).toContain("pnpm render-lume-runner-manifest"); expect(String(lifecycleStep?.run)).toContain("pnpm install-lume-project");