Skip to content

Commit 84fb025

Browse files
authored
feat: Composite action for synchronizing SDK snippets (#82)
* feat(sync-snippets): composite action that pulls SDK snippets New action at actions/sync-snippets/. Resolves the latest launchdarkly/sdk-meta `snippets/*` GitHub Release, downloads the platform's signed binary, cosign-verifies it (keyless / OIDC, identity pinned to sdk-meta's release-please workflow), runs `snippets render` against the consumer checkout, and opens or updates a sync PR via peter-evans/create-pull-request when the render produced any diff. Designed for gonfalon (and future ld-docs-private use); both call this with `@main`. Daily cron + workflow_dispatch on the consumer side keeps the consumer current without a coordinated rollout. Implementation notes: - The `snippets` CLI binary embeds the canonical sdks/ tree at build time (sdk-meta side, separate PR). One artifact, one signature check, atomic content + engine pinning. - `--certificate-identity-regexp` matches sdk-meta's release-please workflow path on main, so a token leaked from any other workflow cannot produce a matching OIDC claim. - Per-platform archive name resolution mirrors goreleaser's default template (snippets_<version>_<os>_<arch>.tar.gz). * feat(sync-snippets): switch to consumer-declared entrypoints The CLI no longer asks each sdk.yaml which file in the consumer to rewrite (see launchdarkly/sdk-meta#405); instead the consumer declares its own list of search roots. Plumb that through: - New required input `entrypoints` (newline-separated list of directories, relative to $GITHUB_WORKSPACE). - Render step parses the input into individual `--entrypoint=` flags and passes them to `snippets render`. - Empty / whitespace-only lines are tolerated and skipped; an empty list fails loudly. - README + the in-action usage block updated to show the new shape. Consumer wiring becomes: - uses: launchdarkly/gh-actions/actions/sync-snippets@main with: entrypoints: | static/ld/components/getStarted github-token: ${{ secrets.GITHUB_TOKEN }} * feat(sync-snippets): switch to github attestation verification The upstream sdk-meta release pipeline is moving from cosign keyless sidecars to GitHub's first-party SLSA build-provenance attestation (launchdarkly/sdk-meta#409). Match it on the verifier side: - Replace `cosign verify-blob` with `gh attestation verify --signer- workflow launchdarkly/sdk-meta/.github/workflows/release-please.yml`. Same identity pinning, but the attestation is fetched from sdk-meta's repo-level attestation store rather than as `.sig`/`.pem` release assets. - Drop the `sigstore/cosign-installer` step. `gh` ships preinstalled on every GitHub-hosted runner. - Drop the `id-token: write` permission from the consumer wiring example — verification is read-only against sdk-meta's attestation store and doesn't need an OIDC token. - Update README copy to match. * fix(sync-snippets): drop literal ${{ from action description The action manifest's description: field is template-evaluated at action-load time. Embedding a copy-paste-able workflow snippet that contained ${{ secrets.GITHUB_TOKEN }} caused 'Unrecognized named-value: secrets' parse failures whenever a consumer used the action — secrets aren't in scope when the manifest itself is being loaded. Replace the inline workflow example with a prose pointer to README.md, which carries the full consumer wiring snippet. * fix(sync-snippets): exclude drafts and pre-releases from latest resolution A 'latest' lookup that catches sdk-meta's release-pipeline mid-publish window would point at a draft release whose archives and attestations haven't uploaded yet — and the subsequent 'gh release download' / 'gh attestation verify' would fail noisily but pointlessly. Pre-releases are excluded under the same logic: consumers asking for 'latest' want the latest stable, not a release-candidate the pipeline cut for testing. Per @keelerm84 review on PR #82.
1 parent 0a54234 commit 84fb025

2 files changed

Lines changed: 303 additions & 0 deletions

File tree

actions/sync-snippets/README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# sync-snippets
2+
3+
A composite action that pulls the canonical SDK code snippets from the latest
4+
[`launchdarkly/sdk-meta`](https://github.com/launchdarkly/sdk-meta) release and
5+
opens a sync PR against the calling repository. Used by gonfalon (and future
6+
consumers like `ld-docs-private`) to stay current with snippet changes without
7+
hand-editing.
8+
9+
## Usage
10+
11+
```yaml
12+
name: Sync SDK snippets
13+
on:
14+
schedule:
15+
- cron: '0 12 * * *' # daily at 12:00 UTC
16+
workflow_dispatch:
17+
18+
permissions:
19+
contents: write
20+
pull-requests: write
21+
22+
jobs:
23+
sync:
24+
runs-on: ubuntu-latest
25+
steps:
26+
- uses: actions/checkout@v4
27+
- uses: launchdarkly/gh-actions/actions/sync-snippets@main
28+
with:
29+
entrypoints: |
30+
static/ld/components/getStarted
31+
github-token: ${{ secrets.GITHUB_TOKEN }}
32+
```
33+
34+
## What it does
35+
36+
1. Resolves the latest `snippets/vX.Y.Z` GitHub Release on `launchdarkly/sdk-meta` (override with `version:` if you need to pin or roll back).
37+
2. Downloads the platform-specific binary archive from the release.
38+
3. Verifies the SLSA build-provenance attestation issued by `launchdarkly/sdk-meta`'s `release-please` workflow via `gh attestation verify`.
39+
4. Runs `snippets render --target=<adapter> --entrypoint=<dir>...` with one `--entrypoint` flag per non-empty line of the `entrypoints:` input. The binary embeds the canonical `sdks/` tree at build time — no separate snippet fetch. The renderer walks each entrypoint recursively, picks up files with extensions it understands (`.tsx`/`.jsx`/`.ts`/`.js`/`.mdx`) that contain the `SDK_SNIPPET:RENDER:` sentinel, and skips junk dirs (`node_modules`, `.git`, `dist`, `build`, ...).
40+
5. Opens (or updates) a pull request with the rewritten files. If `render` produced no diff, the action exits 0 without opening a PR.
41+
42+
## Inputs
43+
44+
| Name | Default | Description |
45+
|---|---|---|
46+
| `entrypoints` | (required) | Newline-separated list of consumer-checkout directories the renderer should walk for snippet markers. Paths resolve against `$GITHUB_WORKSPACE`. |
47+
| `version` | `latest` | Release tag to install (e.g. `snippets/v0.3.0`). `latest` resolves to the most recent published `snippets/*` release. |
48+
| `target` | `ld-application` | Adapter target. `ld-application` for gonfalon. Future targets (e.g. `ld-docs`) plug in here. |
49+
| `branch` | `chore/sync-sdk-snippets` | Branch the action commits the rendered diff to. |
50+
| `pr-title` | `chore: sync SDK snippets` | Pull-request title. |
51+
| `pr-body` | (auto-generated) | Pull-request body. Defaults to a one-liner pointing at the upstream release notes. |
52+
| `pr-labels` | `sdk-snippets,automated-pr` | Comma-separated labels applied to the sync PR. |
53+
| `github-token` | (required) | Token used to download release assets and open the PR. The repo's default `GITHUB_TOKEN` is sufficient when the workflow has `contents: write` and `pull-requests: write`. |
54+
55+
## Outputs
56+
57+
| Name | Description |
58+
|---|---|
59+
| `version` | The release tag that was installed. |
60+
| `changes` | `true` if `render` produced any diff. |
61+
| `pr-number` | Pull-request number when one was opened or updated; empty otherwise. |
62+
63+
## How the supply chain is locked down
64+
65+
- **Signing identity is pinned to a specific workflow path.** `gh attestation verify --signer-workflow launchdarkly/sdk-meta/.github/workflows/release-please.yml` checks the SLSA build-provenance subject against that exact workflow file. A token leaked from any other workflow in any other repo cannot produce a matching OIDC claim.
66+
- **No long-lived signing keys.** Each release attests itself using GitHub's OIDC token, so there is nothing to rotate, store, or accidentally check in. The attestation is published to `launchdarkly/sdk-meta`'s repo-level attestation store, not as a release asset.
67+
- **Snippet sources travel with the binary.** The CLI's `--sdks=` flag is optional; when omitted (the default for this action) it reads from `embed.FS`. Pinning a release version pins both the engine and the snippet content atomically.

actions/sync-snippets/action.yml

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
name: 'Sync SDK Snippets'
2+
description: |
3+
Pulls the canonical SDK code snippets from the latest sdk-meta `snippets/` release
4+
into the current consumer checkout (gonfalon, ld-docs-private), then opens or
5+
updates a sync pull request when the rendered output differs from `main`.
6+
7+
The action is a thin wrapper around the `snippets` CLI. The CLI binary is
8+
downloaded from sdk-meta's GitHub Releases and verified against a
9+
GitHub-issued SLSA build provenance attestation; verification pins the
10+
signing workflow to launchdarkly/sdk-meta's `release-please.yml`, so a
11+
compromised release token can't substitute a different binary without
12+
also faking the OIDC claim issued at build time.
13+
14+
See actions/sync-snippets/README.md for a full consumer-side workflow
15+
example. The action needs `contents: write` and `pull-requests: write`
16+
permissions and the consumer's `GITHUB_TOKEN` passed via the
17+
`github-token` input.
18+
19+
inputs:
20+
version:
21+
description: |
22+
Release tag to install, e.g. `snippets/v0.3.0`. The default `latest`
23+
resolves to the most recent published release whose tag begins with
24+
`snippets/`.
25+
required: false
26+
default: 'latest'
27+
target:
28+
description: |
29+
Adapter target. `ld-application` for gonfalon (and any other consumer that
30+
uses TSX render markers); future targets (e.g. `ld-docs`) plug in here.
31+
required: false
32+
default: 'ld-application'
33+
entrypoints:
34+
description: |
35+
Newline-separated list of consumer-checkout directories the renderer
36+
should walk for snippet markers. Paths are resolved relative to
37+
$GITHUB_WORKSPACE (so a top-level `static/ld/components/getStarted` is
38+
what gonfalon would pass). Each line becomes one `--entrypoint=` flag
39+
to the CLI; the CLI walks each recursively, opens files with extensions
40+
it understands (.tsx/.jsx/.ts/.js/.mdx), and skips junk dirs
41+
(node_modules, .git, dist, build, ...).
42+
required: true
43+
branch:
44+
description: 'Branch the action commits the rendered diff to.'
45+
required: false
46+
default: 'chore/sync-sdk-snippets'
47+
pr-title:
48+
description: 'Pull-request title.'
49+
required: false
50+
default: 'chore: sync SDK snippets'
51+
pr-body:
52+
description: |
53+
Pull-request body. The default points at the upstream release notes for
54+
the version being synced.
55+
required: false
56+
default: ''
57+
pr-labels:
58+
description: 'Comma-separated labels applied to the sync PR.'
59+
required: false
60+
default: 'sdk-snippets,automated-pr'
61+
github-token:
62+
description: |
63+
Token used to download release assets and open the PR. Needs `contents:
64+
write` and `pull-requests: write` on the consumer repo. The repo's default
65+
`GITHUB_TOKEN` is sufficient.
66+
required: true
67+
68+
outputs:
69+
version:
70+
description: 'The sdk-meta snippets release tag that was actually installed.'
71+
value: ${{ steps.resolve.outputs.tag }}
72+
changes:
73+
description: 'true if `snippets render` produced any diff against the working tree.'
74+
value: ${{ steps.diff.outputs.changes }}
75+
pr-number:
76+
description: 'Pull-request number when a sync PR was opened or updated; empty otherwise.'
77+
value: ${{ steps.cpr.outputs.pull-request-number }}
78+
79+
runs:
80+
using: composite
81+
steps:
82+
# 1. Resolve the version. `latest` queries the GitHub API for the most
83+
# recent published release whose tag starts with `snippets/`. Drafts
84+
# and pre-releases are excluded — drafts especially matter because
85+
# sdk-meta's release pipeline cuts a draft, uploads assets, then
86+
# flips it to published. A `latest` query that catches the draft
87+
# window would point at a release whose assets and attestations may
88+
# not have been uploaded yet. Any non-`latest` value is used
89+
# verbatim — caller pins, action obeys.
90+
- id: resolve
91+
name: 'Resolve snippets release tag'
92+
shell: bash
93+
env:
94+
GH_TOKEN: ${{ inputs.github-token }}
95+
run: |
96+
set -euo pipefail
97+
if [ "${{ inputs.version }}" = "latest" ]; then
98+
tag=$(gh release list --repo launchdarkly/sdk-meta --limit 100 \
99+
--exclude-drafts --exclude-pre-releases \
100+
--json tagName --jq '.[] | .tagName | select(startswith("snippets/"))' \
101+
| head -n 1)
102+
if [ -z "$tag" ]; then
103+
echo "::error::no published snippets/* release found in launchdarkly/sdk-meta"
104+
exit 1
105+
fi
106+
else
107+
tag="${{ inputs.version }}"
108+
fi
109+
echo "tag=$tag" >> "$GITHUB_OUTPUT"
110+
echo "Using snippets release: $tag"
111+
112+
# 2. Resolve the platform's archive name. The release workflow names
113+
# archives snippets_<version>_<os>_<arch>.tar.gz, where <version> is
114+
# the bare semver (the `snippets/` tag prefix is stripped).
115+
- id: archive
116+
name: 'Resolve archive filename'
117+
shell: bash
118+
run: |
119+
set -euo pipefail
120+
case "$RUNNER_OS" in
121+
Linux) os=linux ;;
122+
macOS) os=darwin ;;
123+
*) echo "::error::unsupported runner OS: $RUNNER_OS"; exit 1 ;;
124+
esac
125+
case "$RUNNER_ARCH" in
126+
X64) arch=amd64 ;;
127+
ARM64) arch=arm64 ;;
128+
*) echo "::error::unsupported runner arch: $RUNNER_ARCH"; exit 1 ;;
129+
esac
130+
version="${{ steps.resolve.outputs.tag }}"
131+
version="${version#snippets/v}"
132+
archive="snippets_${version}_${os}_${arch}.tar.gz"
133+
echo "archive=$archive" >> "$GITHUB_OUTPUT"
134+
135+
# 3. Pull the archive from the GitHub Release. Attestations are
136+
# fetched separately by `gh attestation verify` from sdk-meta's
137+
# repo-level attestations — they aren't release assets.
138+
- name: 'Download release archive'
139+
shell: bash
140+
working-directory: ${{ runner.temp }}
141+
env:
142+
GH_TOKEN: ${{ inputs.github-token }}
143+
run: |
144+
set -euo pipefail
145+
gh release download "${{ steps.resolve.outputs.tag }}" \
146+
--repo launchdarkly/sdk-meta \
147+
--pattern "${{ steps.archive.outputs.archive }}" \
148+
--clobber
149+
150+
# 4. Verify the SLSA build-provenance attestation. `--signer-workflow`
151+
# pins the signing workflow to sdk-meta's release-please.yml — a
152+
# token leaked from any other workflow couldn't produce a matching
153+
# OIDC claim. `gh` ships preinstalled on every GitHub-hosted
154+
# runner, so no separate install step is required.
155+
- name: 'Verify attestation'
156+
shell: bash
157+
working-directory: ${{ runner.temp }}
158+
env:
159+
GH_TOKEN: ${{ inputs.github-token }}
160+
run: |
161+
set -euo pipefail
162+
gh attestation verify "${{ steps.archive.outputs.archive }}" \
163+
--repo launchdarkly/sdk-meta \
164+
--signer-workflow launchdarkly/sdk-meta/.github/workflows/release-please.yml
165+
166+
# 5. Extract the CLI and stage it on $PATH for the render step.
167+
- name: 'Install CLI'
168+
shell: bash
169+
working-directory: ${{ runner.temp }}
170+
run: |
171+
set -euo pipefail
172+
mkdir -p bin
173+
tar -xzf "${{ steps.archive.outputs.archive }}" -C bin snippets
174+
chmod +x bin/snippets
175+
echo "$PWD/bin" >> "$GITHUB_PATH"
176+
177+
# 6. Render against the consumer checkout. `snippets` is now on $PATH.
178+
# The CLI loads the canonical sdks/ tree from the binary's embedded FS
179+
# — no separate snippet sources to fetch. Each line of the
180+
# `entrypoints:` input becomes one `--entrypoint=` flag (relative paths
181+
# resolve against $GITHUB_WORKSPACE).
182+
- name: 'Render snippets'
183+
shell: bash
184+
env:
185+
ENTRYPOINTS: ${{ inputs.entrypoints }}
186+
run: |
187+
set -euo pipefail
188+
snippets version
189+
cd "$GITHUB_WORKSPACE"
190+
args=()
191+
while IFS= read -r line; do
192+
# Strip surrounding whitespace; skip empty lines.
193+
line="${line#"${line%%[![:space:]]*}"}"
194+
line="${line%"${line##*[![:space:]]}"}"
195+
[ -z "$line" ] && continue
196+
args+=("--entrypoint=$line")
197+
done <<< "$ENTRYPOINTS"
198+
if [ ${#args[@]} -eq 0 ]; then
199+
echo "::error::sync-snippets: at least one non-empty entrypoint must be provided"
200+
exit 1
201+
fi
202+
snippets render --target="${{ inputs.target }}" "${args[@]}"
203+
204+
# 7. Detect whether render touched anything. Empty diff means the consumer
205+
# is already current; we exit cleanly with no PR.
206+
- id: diff
207+
name: 'Check for changes'
208+
shell: bash
209+
run: |
210+
set -euo pipefail
211+
if [ -n "$(git status --porcelain)" ]; then
212+
echo "changes=true" >> "$GITHUB_OUTPUT"
213+
else
214+
echo "changes=false" >> "$GITHUB_OUTPUT"
215+
echo "Consumer tree is already in sync with ${{ steps.resolve.outputs.tag }}."
216+
fi
217+
218+
# 8. Open or update a PR with the rendered diff. peter-evans/create-pull-
219+
# request handles both cases: pushes to the same branch, force-updates
220+
# on collisions, and leaves an existing PR alone if the diff is
221+
# identical to what's already proposed.
222+
- id: cpr
223+
name: 'Open or update sync PR'
224+
if: steps.diff.outputs.changes == 'true'
225+
uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # v7.0.6
226+
with:
227+
token: ${{ inputs.github-token }}
228+
commit-message: 'chore: sync SDK snippets to ${{ steps.resolve.outputs.tag }}'
229+
branch: ${{ inputs.branch }}
230+
delete-branch: true
231+
title: ${{ inputs.pr-title }}
232+
body: |
233+
${{ inputs.pr-body != '' && inputs.pr-body || format('Syncs SDK snippets from upstream release [`{0}`](https://github.com/launchdarkly/sdk-meta/releases/tag/{0}).', steps.resolve.outputs.tag) }}
234+
235+
Generated by `launchdarkly/gh-actions/actions/sync-snippets@main`.
236+
labels: ${{ inputs.pr-labels }}

0 commit comments

Comments
 (0)