From c889c28fa3125d966b8c70716be491cb32807351 Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Tue, 30 Jun 2026 23:32:52 -0500 Subject: [PATCH] Add update-plugin-index reusable workflow Opens a PR against a datumctl plugin catalog whenever a service repo publishes a release, bumping the plugin manifest to the new version: sets spec.version, rewrites each platform download URL to the new tag, and refreshes each sha256 from the release checksums.txt. The archive-to-checksum mapping is driven off the basenames of the manifest's existing platforms[].uri values, so it is generic across plugins. index.yaml regeneration is left to the catalog's own generator. --- .github/workflows/update-plugin-index.yaml | 195 +++++++++++++++++++++ CLAUDE.md | 17 ++ docs/README.md | 1 + docs/update-plugin-index/README.md | 97 ++++++++++ 4 files changed, 310 insertions(+) create mode 100644 .github/workflows/update-plugin-index.yaml create mode 100644 docs/update-plugin-index/README.md diff --git a/.github/workflows/update-plugin-index.yaml b/.github/workflows/update-plugin-index.yaml new file mode 100644 index 0000000..a5f8dcc --- /dev/null +++ b/.github/workflows/update-plugin-index.yaml @@ -0,0 +1,195 @@ +name: Update Plugin Index + +# Opens a pull request against a datumctl plugin catalog (index repo) that bumps +# a single plugin's manifest to a newly published release: it sets spec.version +# and, for every platform entry, rewrites the download URI to point at the new +# tag and refreshes the sha256 from that release's checksums.txt. +# +# The archive-name -> platform mapping is driven entirely off the basenames of +# the manifest's EXISTING platforms[].uri values, so this workflow works for any +# plugin whose release ships a checksums.txt (one ` ` per +# line) alongside per-platform archives. Nothing about a specific plugin (name, +# prefix, archive layout) is hardcoded. +# +# Regenerating index.yaml is intentionally left to the index repo's own +# generator, which reconciles index.yaml from plugins/*.yaml on merge to its +# default branch. This workflow only edits plugins/.yaml. + +on: + workflow_call: + inputs: + index-repo: + required: true + type: string + description: "The plugin catalog repository to open the PR against (e.g. `milo-os/cli-plugins`)." + plugin-name: + required: true + type: string + description: "The plugin's short name, used to locate its manifest and label the PR (e.g. `ipam`)." + plugin-file: + required: false + type: string + description: "Path to the plugin manifest within the index repo. Defaults to `plugins/.yaml`." + default: "" + version: + required: true + type: string + description: "The release tag to publish, including the leading `v` (e.g. `v0.2.0`)." + release-repo: + required: false + type: string + description: "The repository that published the release assets. Defaults to the calling repository." + default: "" + base-branch: + required: false + type: string + description: "Branch in the index repo to base the PR on. Defaults to `main`." + default: "main" + secrets: + PLUGIN_INDEX_TOKEN: + required: true + description: >- + Token with contents:write and pull-requests:write permission on + `index-repo`. A caller's built-in GITHUB_TOKEN is scoped to its own + repository and cannot push a branch or open a PR on the index repo, + so a cross-repo credential (a PAT or a GitHub App installation token) + is required. + +jobs: + update-plugin-index: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Resolve inputs + id: cfg + run: | + plugin_file='${{ inputs.plugin-file }}' + if [ -z "$plugin_file" ]; then + plugin_file="plugins/${{ inputs.plugin-name }}.yaml" + fi + release_repo='${{ inputs.release-repo }}' + if [ -z "$release_repo" ]; then + release_repo='${{ github.repository }}' + fi + { + echo "plugin-file=$plugin_file" + echo "release-repo=$release_repo" + } >> "$GITHUB_OUTPUT" + + # The release assets live in the calling repository, so the built-in + # GITHUB_TOKEN (scoped to that repo) is the right credential here. Fails + # loudly if checksums.txt is not among the release assets. + - name: Download checksums.txt from the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p _release + if ! gh release download "${{ inputs.version }}" \ + --repo "${{ steps.cfg.outputs.release-repo }}" \ + --pattern "checksums.txt" \ + --dir _release; then + echo "::error::checksums.txt not found for ${{ steps.cfg.outputs.release-repo }}@${{ inputs.version }}" + exit 1 + fi + echo "Downloaded checksums.txt:" + cat _release/checksums.txt + + - name: Check out index repo + uses: actions/checkout@v6 + with: + repository: ${{ inputs.index-repo }} + ref: ${{ inputs.base-branch }} + token: ${{ secrets.PLUGIN_INDEX_TOKEN }} + path: index-repo + + - name: Update plugin manifest + env: + PLUGIN_FILE: ${{ steps.cfg.outputs.plugin-file }} + RELEASE_REPO: ${{ steps.cfg.outputs.release-repo }} + VERSION: ${{ inputs.version }} + CHECKSUMS: ${{ github.workspace }}/_release/checksums.txt + run: | + python3 -m pip install --quiet "ruamel.yaml>=0.18" + python3 - <<'PY' + import os, sys + from ruamel.yaml import YAML + + version = os.environ["VERSION"] + release_repo = os.environ["RELEASE_REPO"] + plugin_file = os.path.join("index-repo", os.environ["PLUGIN_FILE"]) + checksums_path = os.environ["CHECKSUMS"] + + # checksums.txt: one " " per line. Key by basename + # so it matches the archive basenames taken from the manifest URIs. + sums = {} + with open(checksums_path) as fh: + for line in fh: + line = line.strip() + if not line: + continue + parts = line.split() + if len(parts) != 2: + print(f"::error::malformed checksums.txt line: {line!r}") + sys.exit(1) + sha, name = parts + sums[os.path.basename(name)] = sha + + if not os.path.exists(plugin_file): + print(f"::error::plugin manifest not found: {plugin_file}") + sys.exit(1) + + yaml = YAML() + yaml.preserve_quotes = True + # Match the catalog's hand-authored style: 4-space sequence indent + # with a 2-space dash offset, and never fold long download URLs. + yaml.indent(mapping=2, sequence=4, offset=2) + yaml.width = 4096 + with open(plugin_file) as fh: + doc = yaml.load(fh) + + doc["spec"]["version"] = version + + missing = [] + for platform in doc["spec"]["platforms"]: + # The existing URI basename is the archive name. It is stable + # across releases (goreleaser archive names carry no version), so + # this mapping is fully generic and never hardcodes a plugin. + basename = platform["uri"].rsplit("/", 1)[-1] + platform["uri"] = ( + f"https://github.com/{release_repo}/releases/download/{version}/{basename}" + ) + if basename not in sums: + missing.append(basename) + continue + platform["sha256"] = sums[basename] + + if missing: + print("::error::archives missing from checksums.txt: " + ", ".join(missing)) + print("available assets: " + ", ".join(sorted(sums))) + sys.exit(1) + + with open(plugin_file, "w") as fh: + yaml.dump(doc, fh) + + print(f"Updated {plugin_file} to {version} across {len(doc['spec']['platforms'])} platform(s)") + PY + + echo "=== Resulting manifest ===" + cat "index-repo/${PLUGIN_FILE}" + + - name: Open pull request against index repo + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.PLUGIN_INDEX_TOKEN }} + path: index-repo + base: ${{ inputs.base-branch }} + branch: update-plugin/${{ inputs.plugin-name }}-${{ inputs.version }} + commit-message: "Update ${{ inputs.plugin-name }} plugin to ${{ inputs.version }}" + title: "Update ${{ inputs.plugin-name }} plugin to ${{ inputs.version }}" + body: | + Updates the `${{ inputs.plugin-name }}` plugin manifest to release **${{ inputs.version }}** of `${{ steps.cfg.outputs.release-repo }}`. + + Every platform's download URL now points at the `${{ inputs.version }}` release assets, and each `sha256` was refreshed from that release's `checksums.txt`. Catalog CI will validate the schema, resolve each URL, and verify each checksum. + + The catalog `index.yaml` is regenerated automatically from `plugins/*.yaml` when this merges. diff --git a/CLAUDE.md b/CLAUDE.md index a0ac5ea..f6123ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,23 @@ Validates workflow YAML files using actionlint. Runs automatically on workflow f Finds and validates all `kustomization.yaml` files in the repository using `kustomize build --enable-helm`. +### 6. Update Plugin Index (`.github/workflows/update-plugin-index.yaml`) + +Opens a PR against a datumctl plugin catalog (index repo) that bumps a plugin's manifest to a newly published release — setting `spec.version`, rewriting each platform's download `uri` to the new tag, and refreshing each `sha256` from the release's `checksums.txt`. + +**Inputs:** +- `index-repo` (required): Catalog repo to open the PR against (e.g. `milo-os/cli-plugins`) +- `plugin-name` (required): Plugin short name (e.g. `ipam`) +- `plugin-file` (optional): Manifest path in the index repo (default `plugins/.yaml`) +- `version` (required): Release tag including leading `v` (e.g. `v0.2.0`) +- `release-repo` (optional): Repo that published the release assets (default: calling repo) +- `base-branch` (optional): Index repo branch to base the PR on (default `main`) + +**Secrets:** +- `PLUGIN_INDEX_TOKEN` (required): PAT / GitHub App token with `contents:write` + `pull-requests:write` on `index-repo`. The built-in `GITHUB_TOKEN` cannot push or open a PR cross-repo. + +The archive→checksum mapping is driven off the basenames of the manifest's existing `platforms[].uri` values, so it works for any plugin. Regenerating `index.yaml` is left to the index repo's own generator. See [`docs/update-plugin-index/`](docs/update-plugin-index/README.md). + ## Common Development Commands ### Linting Workflows diff --git a/docs/README.md b/docs/README.md index 2df297a..ad74ef9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ available for use across the organization. - [**Publish Docker Images**](./publish-docker/) - Build and push Docker images to GitHub Container Registry - [**Publish Kustomize Bundle**](./publish-kustomize-bundle/) - Build and push Kustomize bundles to GitHub Container Registry +- [**Update Plugin Index**](./update-plugin-index/) - Open a PR against a datumctl plugin catalog to bump a plugin manifest to a new release, refreshing version, URLs, and checksums ### Validation & Linting diff --git a/docs/update-plugin-index/README.md b/docs/update-plugin-index/README.md new file mode 100644 index 0000000..15d3f49 --- /dev/null +++ b/docs/update-plugin-index/README.md @@ -0,0 +1,97 @@ +# Update Plugin Index + +The `.github/workflows/update-plugin-index.yaml` reusable GitHub Action opens a +pull request against a [datumctl](https://github.com/datum-cloud/datumctl) +plugin catalog (the "index repo") whenever a service repo publishes a release. +It bumps a single plugin's manifest (`plugins/.yaml`) to the new +version: it sets `spec.version` and, for every platform, rewrites the download +`uri` to point at the new tag and refreshes the `sha256` from the release's +`checksums.txt`. + +This replaces the manual step of hand-editing catalog manifests after each +release. + +## Inputs + +- **index-repo** (required): The plugin catalog repository to open the PR + against (e.g. `milo-os/cli-plugins`). +- **plugin-name** (required): The plugin's short name, used to locate its + manifest and label the PR (e.g. `ipam`). +- **plugin-file** (optional): Path to the plugin manifest within the index repo. + Defaults to `plugins/.yaml`. +- **version** (required): The release tag to publish, including the leading `v` + (e.g. `v0.2.0`). +- **release-repo** (optional): The repository that published the release assets. + Defaults to the calling repository (`github.repository`). +- **base-branch** (optional): Branch in the index repo to base the PR on. + Defaults to `main`. + +## Secrets + +- **PLUGIN_INDEX_TOKEN** (required): A token with `contents:write` and + `pull-requests:write` permission on `index-repo`. A caller's built-in + `GITHUB_TOKEN` is scoped to its own repository and **cannot** push a branch or + open a PR on a different repository, so a cross-repo credential — a Personal + Access Token or a GitHub App installation token — is required. Store it as a + repository or organization secret on the calling repo. + +## How archives are mapped to checksums + +The mapping is fully generic and never hardcodes a plugin name or archive +prefix: + +1. The release's `checksums.txt` is downloaded. Each line is + ` `; entries are keyed by the filename's **basename**. +2. For each entry in the manifest's existing `spec.platforms[]`, the archive + name is taken from the **basename of that platform's current `uri`**. + goreleaser archive names carry no version, so this basename is stable across + releases. +3. That basename is looked up in `checksums.txt` to set the platform's `sha256`, + and the `uri` is rewritten to + `https://github.com//releases/download//`. + +If `checksums.txt` is missing, or any expected archive basename is not listed in +it, the workflow fails loudly. + +## What it does not do + +Regenerating `index.yaml` is left to the index repo's own generator, which +reconciles `index.yaml` from `plugins/*.yaml` on merge to its default branch. +This workflow only edits `plugins/.yaml`. + +## Workflow Example + +Add a job that runs when a release is published: + +```yaml +name: Release + +on: + release: + types: [published] + +jobs: + # ... a job that publishes per-platform archives + checksums.txt as release + # assets (e.g. goreleaser) ... + + update-plugin-index: + needs: [publish-plugin] + if: github.event_name == 'release' + uses: datum-cloud/actions/.github/workflows/update-plugin-index.yaml@v1 + with: + index-repo: milo-os/cli-plugins + plugin-name: ipam + version: ${{ github.event.release.tag_name }} + secrets: + PLUGIN_INDEX_TOKEN: ${{ secrets.PLUGIN_INDEX_TOKEN }} +``` + +## Best Practices + +- Always reference a tagged version (e.g. `@v1`) to prevent unexpected breaking + changes. +- Depend on the release-asset-publishing job (`needs:`) so `checksums.txt` + exists before this workflow reads it. +- Trigger on `release: published` only (or gate with + `if: github.event_name == 'release'`) so the update runs exactly once per + release.