Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-hubs-dispatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"dispatch-release-fanout": major
---

Create initial version
79 changes: 79 additions & 0 deletions actions/dispatch-release-fanout/README.md
Original file line number Diff line number Diff line change
@@ -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 | `<run_id>-<run_attempt>` | 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.
98 changes: 98 additions & 0 deletions actions/dispatch-release-fanout/action.yml
Original file line number Diff line number Diff line change
@@ -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);
}
11 changes: 11 additions & 0 deletions actions/dispatch-release-fanout/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
7 changes: 7 additions & 0 deletions actions/dispatch-release-fanout/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "dispatch-release-fanout",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "actions/dispatch-release-fanout",
"targets": {}
}
117 changes: 117 additions & 0 deletions actions/dispatch-release-fanout/scripts/dispatch.js
Original file line number Diff line number Diff line change
@@ -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", `<code>${producer}</code>`],
["images", `<code>${imagesJson}</code>`],
["correlation-id", `<code>${correlationId}</code>`],
])
.write();
}

module.exports = { run, readInputs, parseImages };
2 changes: 2 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading