From 48de71e487183e41f0a04f1681f39e06bc0b85fd Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 12 Jul 2026 23:42:11 +0100 Subject: [PATCH] Harden reusable workflow contracts Route release and security work to hosted runners, make hosted CI the safe default, pin every external action by commit SHA, and add an executable caller smoke workflow. Document the immutable v0.2.2 consumer contract pending its verified release. --- .github/workflows/ci.yml | 58 +++++++++++++ .github/workflows/reusable-workflow-smoke.yml | 35 ++++++++ .github/workflows/rg-ci.yml | 83 +++++++++++++++---- .github/workflows/rg-release.yml | 64 +++++++------- .github/workflows/rg-security.yml | 42 +++++++--- docs/workflow-cookbook.md | 30 +++++-- test/fixtures/reusable-workflow.Dockerfile | 2 + test/reusable-workflow-contract.test.ts | 68 +++++++++++++++ test/security-workflow.test.ts | 18 ++-- 9 files changed, 330 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/reusable-workflow-smoke.yml create mode 100644 test/fixtures/reusable-workflow.Dockerfile create mode 100644 test/reusable-workflow-contract.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3f923b..afcb32f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,64 @@ concurrency: cancel-in-progress: true jobs: + reusable_contract_changes: + name: reusable workflow smoke scope + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + outputs: + changed: ${{ steps.filter.outputs.changed }} + steps: + - uses: actions/checkout@v7 + - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4 + id: filter + with: + filters: | + changed: + - '.github/workflows/rg-ci.yml' + - '.github/workflows/rg-security.yml' + - '.github/workflows/rg-release.yml' + - '.github/workflows/reusable-workflow-smoke.yml' + - 'test/reusable-workflow-contract.test.ts' + - 'test/fixtures/reusable-workflow.Dockerfile' + + reusable_ci_smoke: + name: reusable rg-ci caller smoke + needs: reusable_contract_changes + if: needs.reusable_contract_changes.outputs.changed == 'true' + uses: ./.github/workflows/rg-ci.yml + with: + runner-class: hosted + + reusable_security_smoke: + name: reusable rg-security caller smoke + needs: reusable_contract_changes + if: needs.reusable_contract_changes.outputs.changed == 'true' + permissions: + contents: read + actions: read + pull-requests: read + security-events: write + uses: ./.github/workflows/rg-security.yml + with: + enable-dependency-review: false + enforce-osv: false + + reusable_release_smoke: + name: reusable rg-release caller smoke + needs: reusable_contract_changes + if: needs.reusable_contract_changes.outputs.changed == 'true' + permissions: + contents: read + packages: write + id-token: write + attestations: write + uses: ./.github/workflows/rg-release.yml + with: + image-ref: local/reusable-workflow-smoke:${{ github.sha }} + dockerfile: test/fixtures/reusable-workflow.Dockerfile + platforms: linux/amd64 + publish: false + test_self_hosted_trusted: name: test if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository diff --git a/.github/workflows/reusable-workflow-smoke.yml b/.github/workflows/reusable-workflow-smoke.yml new file mode 100644 index 0000000..8c9d00e --- /dev/null +++ b/.github/workflows/reusable-workflow-smoke.yml @@ -0,0 +1,35 @@ +name: Reusable Workflow Caller Smoke + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + ci: + uses: ./.github/workflows/rg-ci.yml + with: + runner-class: hosted + + security: + permissions: + contents: read + actions: read + pull-requests: read + security-events: write + uses: ./.github/workflows/rg-security.yml + with: + enable-dependency-review: false + enforce-osv: false + + release: + permissions: + contents: read + uses: ./.github/workflows/rg-release.yml + with: + image-ref: local/reusable-workflow-smoke:${{ github.sha }} + dockerfile: test/fixtures/reusable-workflow.Dockerfile + platforms: linux/amd64 + publish: false diff --git a/.github/workflows/rg-ci.yml b/.github/workflows/rg-ci.yml index 7bbe4dc..308c184 100644 --- a/.github/workflows/rg-ci.yml +++ b/.github/workflows/rg-ci.yml @@ -1,17 +1,23 @@ # Reusable rg-ci workflow. -# Required secrets: none. Optional secrets are inherited by callers for their own scripts. +# Required secrets: none. +# Inputs: runner-class selects hosted (default, safe for forks) or the public shell-only fleet. name: rg-ci on: workflow_call: inputs: + runner-class: + description: "Execution boundary: hosted or shell-safe-public." + required: false + type: string + default: hosted node-version: - description: Node.js version used by hosted lanes. + description: Exact Node.js version. required: false type: string - default: "24" + default: "24.14.1" package-manager-version: - description: pnpm version used by hosted lanes. + description: pnpm version. required: false type: string default: "10.32.1" @@ -28,20 +34,31 @@ env: SHELL: /bin/bash jobs: - ci: - name: rg-ci - runs-on: - - self-hosted - - linux - - shell-only - - public + validate-inputs: + name: rg-ci input contract + runs-on: ubuntu-24.04 + steps: + - name: Validate runner class + env: + RUNNER_CLASS: ${{ inputs.runner-class }} + run: | + case "${RUNNER_CLASS}" in + hosted|shell-safe-public) ;; + *) echo "runner-class must be hosted or shell-safe-public" >&2; exit 2 ;; + esac + + hosted: + name: rg-ci hosted + needs: validate-inputs + if: inputs.runner-class == 'hosted' + runs-on: ubuntu-24.04 timeout-minutes: 20 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ inputs.node-version }} - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: version: ${{ inputs.package-manager-version }} - run: pnpm install --frozen-lockfile @@ -49,3 +66,41 @@ jobs: - run: pnpm test - if: inputs.run-build run: pnpm build + + shell-safe-public: + name: rg-ci shell-safe public + needs: validate-inputs + if: inputs.runner-class == 'shell-safe-public' + runs-on: [self-hosted, linux, shell-only, public] + timeout-minutes: 20 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - name: Install shell-safe Node + env: + NODE_VERSION: ${{ inputs.node-version }} + run: | + set -euo pipefail + case "$(uname -m)" in + x86_64) node_arch=x64 ;; + aarch64|arm64) node_arch=arm64 ;; + *) echo "unsupported architecture: $(uname -m)" >&2; exit 2 ;; + esac + runner_temp="${RUNNER_TEMP:-/tmp/github-runner-temp}" + archive="node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" + install_dir="${runner_temp}/node-${NODE_VERSION}-${node_arch}" + mkdir -p "${runner_temp}" "${install_dir}" + curl --fail --silent --show-error --location --connect-timeout 10 --max-time 120 \ + "https://nodejs.org/dist/v${NODE_VERSION}/${archive}" --output "${runner_temp}/${archive}" + tar -xJf "${runner_temp}/${archive}" --strip-components=1 --no-same-owner -C "${install_dir}" + echo "${install_dir}/bin" >> "${GITHUB_PATH}" + - name: Enable pnpm + env: + PNPM_VERSION: ${{ inputs.package-manager-version }} + run: | + corepack enable + corepack prepare "pnpm@${PNPM_VERSION}" --activate + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm test + - if: inputs.run-build + run: pnpm build diff --git a/.github/workflows/rg-release.yml b/.github/workflows/rg-release.yml index b85a6c5..9fb15a0 100644 --- a/.github/workflows/rg-release.yml +++ b/.github/workflows/rg-release.yml @@ -1,5 +1,6 @@ # Reusable rg-release workflow. -# Required secrets: registry credentials via secrets.GITHUB_TOKEN or inherited registry token. +# Required secrets: GITHUB_TOKEN, supplied automatically by GitHub. No long-lived registry key. +# The caller must grant packages: write, id-token: write, and attestations: write when publish=true. name: rg-release on: @@ -19,6 +20,16 @@ on: required: false type: string default: . + platforms: + description: Comma-separated Docker platforms. + required: false + type: string + default: linux/amd64,linux/arm64 + publish: + description: Push, sign, and attest the image. Set false only for caller contract smoke tests. + required: false + type: boolean + default: true permissions: contents: read @@ -29,49 +40,36 @@ permissions: jobs: release: name: rg-release - runs-on: - - self-hosted - - linux - - shell-only - - public + runs-on: ubuntu-24.04 timeout-minutes: 45 steps: - - uses: actions/checkout@v7 - - uses: docker/setup-qemu-action@v4 - - uses: docker/setup-buildx-action@v4 - - uses: docker/login-action@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + if: inputs.publish with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@v7 + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 id: build with: context: ${{ inputs.context }} file: ${{ inputs.dockerfile }} - platforms: linux/amd64,linux/arm64 - push: true + platforms: ${{ inputs.platforms }} + push: ${{ inputs.publish }} 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@v4 + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + if: inputs.publish + - name: Sign immutable image digest + if: inputs.publish + env: + IMAGE_REF: ${{ inputs.image-ref }} + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: cosign sign --yes "${IMAGE_REF}@${IMAGE_DIGEST}" + - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4 + if: inputs.publish with: subject-name: ${{ inputs.image-ref }} subject-digest: ${{ steps.build.outputs.digest }} diff --git a/.github/workflows/rg-security.yml b/.github/workflows/rg-security.yml index c66bc2d..339efda 100644 --- a/.github/workflows/rg-security.yml +++ b/.github/workflows/rg-security.yml @@ -1,5 +1,5 @@ # Reusable rg-security workflow. -# Required secrets: none. Runs only on GitHub-hosted runners. +# Required secrets: none. The caller must grant security-events: write for SARIF upload. name: rg-security on: @@ -10,6 +10,21 @@ on: required: false type: string default: pnpm-lock.yaml + codeql-languages: + description: Comma-separated CodeQL languages. + required: false + type: string + default: javascript-typescript + enable-dependency-review: + description: Run dependency review on pull requests; requires the caller repository dependency graph. + required: false + type: boolean + default: true + enforce-osv: + description: Fail when OSV reports vulnerable dependencies; SARIF is uploaded in either mode. + required: false + type: boolean + default: true permissions: contents: read @@ -20,27 +35,30 @@ permissions: jobs: security: name: rg-security - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 steps: - - uses: actions/checkout@v7 - - uses: github/codeql-action/init@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 with: - languages: javascript-typescript,python - - uses: github/codeql-action/analyze@v4 - - uses: actions/dependency-review-action@v5 - if: github.event_name == 'pull_request' - continue-on-error: true + languages: ${{ inputs.codeql-languages }} + - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + if: inputs.enable-dependency-review && github.event_name == 'pull_request' with: fail-on-severity: high - - uses: google/osv-scanner-action/osv-scanner-action@v2.3.8 + - uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + id: osv continue-on-error: true with: scan-args: |- --lockfile=${{ inputs.lockfile }} --format=sarif --output=osv-results.sarif - - uses: github/codeql-action/upload-sarif@v4 - if: always() + - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 + if: always() && hashFiles('osv-results.sarif') != '' with: sarif_file: osv-results.sarif + - name: Enforce OSV result + if: inputs.enforce-osv && steps.osv.outcome == 'failure' + run: exit 1 diff --git a/docs/workflow-cookbook.md b/docs/workflow-cookbook.md index ff61b83..2b9a88a 100644 --- a/docs/workflow-cookbook.md +++ b/docs/workflow-cookbook.md @@ -38,14 +38,25 @@ Downstream repos can consume the canonical governance lanes without copying YAML ```yaml jobs: ci: - uses: OMT-Global/github-runner-fleet/.github/workflows/rg-ci.yml@v1 + uses: OMT-Global/github-runner-fleet/.github/workflows/rg-ci.yml@v0.2.2 + with: + runner-class: hosted security: - uses: OMT-Global/github-runner-fleet/.github/workflows/rg-security.yml@v1 + permissions: + contents: read + actions: read + pull-requests: read + security-events: write + uses: OMT-Global/github-runner-fleet/.github/workflows/rg-security.yml@v0.2.2 + # Set false only when the caller repository does not provide Dependency Graph. + with: + enable-dependency-review: true + enforce-osv: true release: if: startsWith(github.ref, 'refs/tags/v') - uses: OMT-Global/github-runner-fleet/.github/workflows/rg-release.yml@v1 + uses: OMT-Global/github-runner-fleet/.github/workflows/rg-release.yml@v0.2.2 permissions: contents: read packages: write @@ -55,9 +66,16 @@ jobs: image-ref: ghcr.io/omt-global/example:${{ github.ref_name }} ``` -Keep `rg-security` and `rg-release` on GitHub-hosted runners. Reference an exact -tag such as `v1.2.3` when reproducibility matters, or a compatibility tag such -as `v1` for the standard org lane. +`rg-security` and `rg-release` always use GitHub-hosted runners. `rg-ci` defaults +to hosted execution so fork pull requests do not reach self-hosted capacity; set +`runner-class: shell-safe-public` only in a trusted caller job. Reference an exact +published release tag. The `v0.2.2` contract becomes consumable after its verified +release is published; do not point consumers at a branch or a moving compatibility +tag. + +Every external action inside these workflows is pinned to a full commit SHA. The +human-readable version comments are maintained by Dependabot, but the resolved +workflow dependency remains immutable between reviewed updates. ## Recipe: trusted Node job on the Synology shell-only pool diff --git a/test/fixtures/reusable-workflow.Dockerfile b/test/fixtures/reusable-workflow.Dockerfile new file mode 100644 index 0000000..ccd9f30 --- /dev/null +++ b/test/fixtures/reusable-workflow.Dockerfile @@ -0,0 +1,2 @@ +FROM scratch +COPY package.json /package.json diff --git a/test/reusable-workflow-contract.test.ts b/test/reusable-workflow-contract.test.ts new file mode 100644 index 0000000..55ab969 --- /dev/null +++ b/test/reusable-workflow-contract.test.ts @@ -0,0 +1,68 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import YAML from "yaml"; + +const reusableFiles = ["rg-ci.yml", "rg-security.yml", "rg-release.yml"]; + +describe("reusable workflow consumer contract", () => { + test("pins every external action to a full commit SHA", () => { + for (const file of reusableFiles) { + const document = YAML.parse(read(`.github/workflows/${file}`)) as { + jobs: Record }>; + }; + const uses = Object.values(document.jobs) + .flatMap((job) => job.steps ?? []) + .flatMap((step) => step.uses ? [step.uses] : []); + expect(uses.length, file).toBeGreaterThan(0); + for (const action of uses) { + expect(action, `${file}: ${action}`).toMatch(/^[^@]+@[0-9a-f]{40}$/); + } + } + }); + + test("routes privileged and untrusted-safe lanes to hosted runners", () => { + const security = parse("rg-security.yml"); + const release = parse("rg-release.yml"); + const ci = parse("rg-ci.yml"); + expect(security.jobs.security["runs-on"]).toBe("ubuntu-24.04"); + expect(release.jobs.release["runs-on"]).toBe("ubuntu-24.04"); + expect(ci.jobs.hosted["runs-on"]).toBe("ubuntu-24.04"); + expect(ci.jobs["shell-safe-public"]["runs-on"]).toEqual([ + "self-hosted", "linux", "shell-only", "public" + ]); + }); + + test("calls all three workflows from a real smoke caller", () => { + const smoke = YAML.parse(read(".github/workflows/reusable-workflow-smoke.yml")) as { + jobs: Record }>; + }; + expect(smoke.jobs.ci.uses).toBe("./.github/workflows/rg-ci.yml"); + expect(smoke.jobs.security.uses).toBe("./.github/workflows/rg-security.yml"); + expect(smoke.jobs.security.with?.["enable-dependency-review"]).toBe(false); + expect(smoke.jobs.security.with?.["enforce-osv"]).toBe(false); + expect(smoke.jobs.release.uses).toBe("./.github/workflows/rg-release.yml"); + expect(smoke.jobs.release.with?.publish).toBe(false); + const ci = read(".github/workflows/ci.yml"); + expect(ci).toContain("uses: ./.github/workflows/rg-ci.yml"); + expect(ci).toContain("uses: ./.github/workflows/rg-security.yml"); + expect(ci).toContain("uses: ./.github/workflows/rg-release.yml"); + expect(ci).toContain("reusable_contract_changes"); + }); + + test("documents only the planned immutable release reference", () => { + const cookbook = read("docs/workflow-cookbook.md"); + expect(cookbook).not.toContain("@v1"); + for (const file of reusableFiles) { + expect(cookbook).toContain(`/.github/workflows/${file}@v0.2.2`); + } + }); +}); + +function parse(file: string): { jobs: Record> } { + return YAML.parse(read(`.github/workflows/${file}`)); +} + +function read(relativePath: string): string { + return fs.readFileSync(path.resolve(relativePath), "utf8"); +} diff --git a/test/security-workflow.test.ts b/test/security-workflow.test.ts index 6edbe3d..ce0dc67 100644 --- a/test/security-workflow.test.ts +++ b/test/security-workflow.test.ts @@ -51,24 +51,32 @@ describe("security and reusable workflows", () => { }); test("exposes rg-ci, rg-security, and rg-release as workflow_call artifacts", () => { - for (const fileName of ["rg-ci.yml", "rg-release.yml"]) { + for (const fileName of ["rg-ci.yml", "rg-security.yml", "rg-release.yml"]) { const workflow = YAML.parse( fs.readFileSync(path.resolve(".github/workflows", fileName), "utf8") ) as { on: Record; jobs: Record> }; expect(workflow.on).toHaveProperty("workflow_call"); - for (const job of Object.values(workflow.jobs)) { - expect(job["runs-on"]).toEqual(shellSafePublicRunner); - } } + const rgCi = YAML.parse( + fs.readFileSync(path.resolve(".github/workflows/rg-ci.yml"), "utf8") + ) as { jobs: Record> }; + expect(rgCi.jobs["shell-safe-public"]["runs-on"]).toEqual(shellSafePublicRunner); + expect(rgCi.jobs.hosted["runs-on"]).toBe("ubuntu-24.04"); + const rgSecurity = YAML.parse( fs.readFileSync(path.resolve(".github/workflows/rg-security.yml"), "utf8") ) as { on: Record; jobs: Record> }; expect(rgSecurity.on).toHaveProperty("workflow_call"); for (const job of Object.values(rgSecurity.jobs)) { - expect(job["runs-on"]).toBe("ubuntu-latest"); + expect(job["runs-on"]).toBe("ubuntu-24.04"); } + + const rgRelease = YAML.parse( + fs.readFileSync(path.resolve(".github/workflows/rg-release.yml"), "utf8") + ) as { jobs: Record> }; + expect(rgRelease.jobs.release["runs-on"]).toBe("ubuntu-24.04"); }); });