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"); }); });