diff --git a/.changeset/brave-hubs-dispatch.md b/.changeset/brave-hubs-dispatch.md new file mode 100644 index 000000000..3c2ab7bb8 --- /dev/null +++ b/.changeset/brave-hubs-dispatch.md @@ -0,0 +1,5 @@ +--- +"dispatch-release-fanout": major +--- + +Create initial version diff --git a/actions/dispatch-release-fanout/README.md b/actions/dispatch-release-fanout/README.md new file mode 100644 index 000000000..c973d7454 --- /dev/null +++ b/actions/dispatch-release-fanout/README.md @@ -0,0 +1,79 @@ +# dispatch-release-fanout + +Tells a release fan-out hub which images a build published. The hub classifies +each tag into a release channel and opens image-bump PRs in the deployment repos +subscribed to it. One call carries every image a build published. + +The dispatch carries this job's GitHub OIDC token as the `id-token` input. The +hub verifies it and checks that `repository` equals `producer` and that +`workflow_ref` matches the producer's pinned workflow and ref. A person cannot +mint this token, so the hub cannot be dispatched by hand. + +## Usage + +The calling job needs `id-token: write`. + +```yaml +jobs: + dispatch-release-fanout: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: smartcontractkit/.github/actions/dispatch-release-fanout@dispatch-release-fanout/v1 + with: + hub-repo: ${{ secrets.FANOUT_HUB_REPO }} + audience: ${{ secrets.FANOUT_HUB_AUDIENCE }} + gati-profile: ${{ secrets.FANOUT_HUB_GATI_PROFILE }} + images: | + core=${{ needs.build.outputs.core-tag }} + ccip=${{ needs.build.outputs.ccip-tag }} +``` + +An image whose tag is empty is dropped, so an optional image can be passed +unconditionally: + +```yaml +images: | + core=${{ needs.release.outputs.core-tag }} + ccip=${{ needs.release.outputs.promote-ccip == 'true' && needs.release.outputs.ccip-tag || '' }} +``` + +If every tag is empty, the action warns, sets `dispatched=false` and does not +dispatch. + +## Inputs + +| input | required | default | description | +| ---------------- | -------- | -------------------------- | ---------------------------------------------------------- | +| `hub-repo` | yes | | `owner/name` of the hub repository. Supply from a secret. | +| `audience` | yes | | OIDC audience the hub expects. Supply from a secret. | +| `gati-profile` | yes | | GATI v2 profile with `actions:write` on the hub. | +| `images` | yes | | Multiline `stream=tag` pairs. | +| `producer` | no | `${{ github.repository }}` | Producer key. The hub requires it to equal the repository. | +| `hub-workflow` | no | `fanout.yaml` | Workflow file in the hub. | +| `hub-ref` | no | `main` | Branch in the hub to run the workflow from. | +| `correlation-id` | no | `-` | Id carried along every hop of the fan-out. | +| `source-run-url` | no | this run's URL | Recorded by every downstream hop. | + +## Outputs + +| output | description | +| ------------ | ----------------------------------------------- | +| `dispatched` | `true` if the hub was dispatched, else `false`. | +| `images` | JSON array of `{stream, tag}` sent to the hub. | + +## Notes + +- Pass `hub-repo`, `audience` and `gati-profile` from secrets. The runner prints + each step's inputs before it runs, so only secrets are masked from the start. + The action also masks `hub-repo` and `audience` for later log lines, and never + writes them to the step summary. +- The token's `workflow_ref` is the caller's top-level workflow, whether this + action is called directly or from a reusable workflow. If the dispatch moves + to another top-level workflow, update the producer's identity in the hub. +- The token expires about 5 minutes after it is minted. To redeliver a release, + re-run this job in the producer. Re-running all jobs of the hub's run fails + because the token it received has expired; re-running only the failed jobs of + the hub's run works. diff --git a/actions/dispatch-release-fanout/action.yml b/actions/dispatch-release-fanout/action.yml new file mode 100644 index 000000000..76a73c3a3 --- /dev/null +++ b/actions/dispatch-release-fanout/action.yml @@ -0,0 +1,98 @@ +name: dispatch-release-fanout +description: + "Tell a release fan-out hub which images a build published, authenticated by + this job's GitHub OIDC token" + +# The hub, the OIDC audience it expects and the GATI profile that reaches it are +# all inputs so this public repository commits no internal names. Pass them from +# secrets. The calling job needs `permissions.id-token: write`. + +inputs: + hub-repo: + description: + "owner/name of the hub repository to dispatch. Supply from a secret." + required: true + audience: + description: + "OIDC audience the hub verifies the id-token against. Supply from a + secret." + required: true + gati-profile: + description: + "GATI v2 token profile granting actions:write on the hub repository. + Supply from a secret." + required: true + images: + description: | + Multiline `stream=tag` pairs, one per image this build published: + + core=2.65.1-rc.0 + ccip=2.65.1-ccip-rc.0 + + `stream` must be declared by the producer in the hub. Entries with an + empty tag are dropped, so a caller can pass an image whose build was + skipped without special-casing it. + required: true + producer: + description: + "Producer key the hub routes on. The hub requires it to equal the + repository in the OIDC token, so the default is almost always right." + required: false + default: ${{ github.repository }} + hub-workflow: + description: "Workflow file in the hub to dispatch." + required: false + default: "fanout.yaml" + hub-ref: + description: "Branch in the hub to run the workflow from." + required: false + default: "main" + correlation-id: + description: + "Opaque id traversing along every hop of the fan-out. Defaults to this + run." + required: false + source-run-url: + description: + "URL of this run, recorded by every downstream hop. Defaults to this run." + required: false + +outputs: + dispatched: + description: "`true` if the hub was dispatched, `false` if no images were." + value: ${{ steps.dispatch.outputs.dispatched }} + images: + description: "JSON array of `{stream, tag}` sent to the hub." + value: ${{ steps.dispatch.outputs.images }} + +runs: + using: composite + steps: + - name: Setup GitHub Token + id: setup-github-token + uses: smartcontractkit/.github/actions/setup-github-token@setup-github-token/v1 + with: + profile: ${{ inputs.gati-profile }} + + - name: Dispatch fan-out + id: dispatch + uses: actions/github-script@v9 + env: + ACTION_PATH: ${{ github.action_path }} + HUB_REPO: ${{ inputs.hub-repo }} + AUDIENCE: ${{ inputs.audience }} + HUB_WORKFLOW: ${{ inputs.hub-workflow }} + HUB_REF: ${{ inputs.hub-ref }} + PRODUCER: ${{ inputs.producer }} + IMAGES: ${{ inputs.images }} + CORRELATION_ID: ${{ inputs.correlation-id }} + SOURCE_RUN_URL: ${{ inputs.source-run-url }} + with: + github-token: ${{ steps.setup-github-token.outputs.access-token }} + script: | + const { run } = require(`${process.env.ACTION_PATH}/scripts/dispatch.js`); + try { + await run({ github, core }); + } catch (err) { + core.setFailed(err.message); + } diff --git a/actions/dispatch-release-fanout/package.json b/actions/dispatch-release-fanout/package.json new file mode 100644 index 000000000..1d9ad3d85 --- /dev/null +++ b/actions/dispatch-release-fanout/package.json @@ -0,0 +1,11 @@ +{ + "name": "dispatch-release-fanout", + "version": "0.1.0", + "description": "Dispatches a release fan-out hub with the images a build published, authenticated by the caller's OIDC token", + "private": true, + "scripts": {}, + "author": "@smartcontractkit", + "license": "MIT", + "dependencies": {}, + "repository": "https://github.com/smartcontractkit/.github" +} diff --git a/actions/dispatch-release-fanout/project.json b/actions/dispatch-release-fanout/project.json new file mode 100644 index 000000000..972ac7779 --- /dev/null +++ b/actions/dispatch-release-fanout/project.json @@ -0,0 +1,7 @@ +{ + "name": "dispatch-release-fanout", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "actions/dispatch-release-fanout", + "targets": {} +} diff --git a/actions/dispatch-release-fanout/scripts/dispatch.js b/actions/dispatch-release-fanout/scripts/dispatch.js new file mode 100644 index 000000000..d43d391f8 --- /dev/null +++ b/actions/dispatch-release-fanout/scripts/dispatch.js @@ -0,0 +1,117 @@ +function parseImages(raw) { + const images = []; + for (const [i, line] of (raw || "").split("\n").entries()) { + const entry = line.trim(); + if (!entry) continue; + const parts = entry.split("=").map((p) => p.trim()); + if (parts.length !== 2 || !parts[0]) { + throw new Error( + `images line ${i + 1}: expected "stream=tag", got "${entry}"`, + ); + } + const [stream, tag] = parts; + if (!tag) continue; + images.push({ stream, tag }); + } + return images; +} + +function readInputs(env) { + const [owner, repo, ...rest] = (env.HUB_REPO || "").split("/"); + if (!owner || !repo || rest.length > 0) { + throw new Error("hub-repo must be in the form owner/name"); + } + if (!env.AUDIENCE) { + throw new Error("audience is required"); + } + return { + owner, + repo, + audience: env.AUDIENCE, + hubWorkflow: env.HUB_WORKFLOW, + hubRef: env.HUB_REF, + producer: env.PRODUCER, + images: parseImages(env.IMAGES), + correlationId: + env.CORRELATION_ID || `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`, + sourceRunUrl: + env.SOURCE_RUN_URL || + `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`, + }; +} + +async function run({ github, core }) { + const env = process.env; + + // Inputs passed as vars or literals aren't masked, and these name internal services. + for (const value of [env.HUB_REPO, env.AUDIENCE]) { + if (value) core.setSecret(value); + } + + const { + owner, + repo, + audience, + hubWorkflow, + hubRef, + producer, + images, + correlationId, + sourceRunUrl, + } = readInputs(env); + + const imagesJson = JSON.stringify(images); + core.setOutput("images", imagesJson); + + if (images.length === 0) { + core.warning("No published images to fan out; nothing dispatched."); + core.setOutput("dispatched", "false"); + return; + } + + // Minted last: the hub rejects it once GitHub's ~5 minute expiry passes. + const idToken = await core.getIDToken(audience).catch((err) => { + throw new Error( + `Could not mint an OIDC token; the calling job needs "permissions: id-token: write". ${err.message}`, + ); + }); + core.setSecret(idToken); + + await github.rest.actions + .createWorkflowDispatch({ + owner, + repo, + workflow_id: hubWorkflow, + ref: hubRef, + inputs: { + producer, + "id-token": idToken, + images: imagesJson, + "correlation-id": correlationId, + "source-run-url": sourceRunUrl, + }, + }) + .catch((err) => { + // Rethrow message-only: the Octokit error's request body holds the id-token. + const status = err.status ? ` (HTTP ${err.status})` : ""; + throw new Error(`Dispatch failed${status}: ${err.message}`); + }); + + core.setOutput("dispatched", "true"); + + // The step summary is not masked, so it must never include the hub or audience. + await core.summary + .addHeading("Release fan-out dispatched", 3) + .addTable([ + [ + { data: "field", header: true }, + { data: "value", header: true }, + ], + ["producer", `${producer}`], + ["images", `${imagesJson}`], + ["correlation-id", `${correlationId}`], + ]) + .write(); +} + +module.exports = { run, readInputs, parseImages }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bb204677..804c82bd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -476,6 +476,8 @@ importers: actions/devenv-k8s-setup-ns: {} + actions/dispatch-release-fanout: {} + actions/dispatch-workflow: {} actions/do-not-merge: {}