From 4f10dc0a287f496af03611a3279a88a14893560d Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 1 Jul 2026 00:55:56 -0500 Subject: [PATCH 1/2] Convert update-plugin-index to a composite action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GitHub App installation token minted in one job and passed to a reusable workflow (a separate job) is scrubbed to empty — masked values don't survive job-to-job outputs, so the reusable-workflow form couldn't actually check out the catalog repo. A composite action runs inside the caller's job, so the token is minted and used in one job and passed directly as the `token` input. Same logic and generic archive->checksum mapping as before; the cross-repo credential moves from a `PLUGIN_INDEX_TOKEN` secret to a `token` input. --- .github/workflows/update-plugin-index.yaml | 195 --------------------- CLAUDE.md | 8 +- docs/update-plugin-index/README.md | 76 +++++--- update-plugin-index/action.yml | 190 ++++++++++++++++++++ 4 files changed, 241 insertions(+), 228 deletions(-) delete mode 100644 .github/workflows/update-plugin-index.yaml create mode 100644 update-plugin-index/action.yml diff --git a/.github/workflows/update-plugin-index.yaml b/.github/workflows/update-plugin-index.yaml deleted file mode 100644 index a5f8dcc..0000000 --- a/.github/workflows/update-plugin-index.yaml +++ /dev/null @@ -1,195 +0,0 @@ -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 f6123ce..2ec8f22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,9 +98,9 @@ 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`) +### 6. Update Plugin Index (`update-plugin-index/action.yml` — composite action) -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`. +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`. It is a **composite action** (not a reusable workflow) so the caller mints the cross-repo token and runs this in the same job — a GitHub App token doesn't survive being passed across jobs. **Inputs:** - `index-repo` (required): Catalog repo to open the PR against (e.g. `milo-os/cli-plugins`) @@ -109,9 +109,7 @@ Opens a PR against a datumctl plugin catalog (index repo) that bumps a plugin's - `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. +- `token` (required): PAT / GitHub App token with `contents:write` + `pull-requests:write` on `index-repo`, minted in the same job. 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). diff --git a/docs/update-plugin-index/README.md b/docs/update-plugin-index/README.md index 15d3f49..7eaf4fe 100644 --- a/docs/update-plugin-index/README.md +++ b/docs/update-plugin-index/README.md @@ -1,16 +1,26 @@ # 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`. +The `update-plugin-index` composite 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. +## Why a composite action (not a reusable workflow) + +Cross-repo PRs need a token the caller's built-in `GITHUB_TOKEN` can't provide +(it only reaches its own repo), so callers mint a GitHub App installation token. +A GitHub App token minted in one job and handed to a **reusable workflow** (a +separate job) is scrubbed to an empty string — masked values do not survive +job-to-job outputs. A **composite action** runs inside the caller's job, so the +mint step and this action share one job and the token is passed directly as an +input. Mint and use must live in the same job; that's the whole reason this is +an action rather than a reusable workflow. + ## Inputs - **index-repo** (required): The plugin catalog repository to open the PR @@ -25,22 +35,18 @@ release. 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. +- **token** (required): A token with `contents:write` and `pull-requests:write` + on `index-repo`. Mint it **in the same job** (e.g. with + `actions/create-github-app-token`) and pass it here. The caller's built-in + `GITHUB_TOKEN` cannot push a branch or open a PR on a different repository. ## 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 +1. The release's `checksums.txt` is downloaded (using the caller's + `GITHUB_TOKEN`, since the release lives in the calling repo). 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`**. @@ -51,17 +57,18 @@ prefix: `https://github.com//releases/download//`. If `checksums.txt` is missing, or any expected archive basename is not listed in -it, the workflow fails loudly. +it, the action 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`. +This action only edits `plugins/.yaml`. ## Workflow Example -Add a job that runs when a release is published: +Mint the App token and call the action in a **single job**, gated on the release +event: ```yaml name: Release @@ -77,21 +84,34 @@ jobs: 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 }} + runs-on: ubuntu-latest + steps: + - name: Mint catalog token from a GitHub App + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.PLUGIN_INDEX_APP_ID }} + private-key: ${{ secrets.PLUGIN_INDEX_APP_PRIVATE_KEY }} + owner: milo-os + repositories: cli-plugins + + - name: Open the catalog PR + uses: datum-cloud/actions/update-plugin-index@v1 + with: + index-repo: milo-os/cli-plugins + plugin-name: ipam + version: ${{ github.event.release.tag_name }} + token: ${{ steps.app-token.outputs.token }} ``` ## Best Practices - Always reference a tagged version (e.g. `@v1`) to prevent unexpected breaking changes. +- Mint the token in the **same job** and pass it via `token:` — never across + jobs. - Depend on the release-asset-publishing job (`needs:`) so `checksums.txt` - exists before this workflow reads it. + exists before this action reads it. - Trigger on `release: published` only (or gate with `if: github.event_name == 'release'`) so the update runs exactly once per release. diff --git a/update-plugin-index/action.yml b/update-plugin-index/action.yml new file mode 100644 index 0000000..c54f9bf --- /dev/null +++ b/update-plugin-index/action.yml @@ -0,0 +1,190 @@ +name: Update Plugin Index +description: >- + Open 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. + +# A composite action (not a reusable workflow) so the caller can mint the +# cross-repo token and run this in the SAME job — the token is passed as the +# `token` input rather than a secret. This matters: a GitHub App token minted in +# one job and passed to another via job outputs is scrubbed to empty (masked +# values don't survive job-to-job hops), which is why this is a composite action +# rather than a reusable workflow. +# +# The archive-name -> platform mapping is driven entirely off the basenames of +# the manifest's EXISTING platforms[].uri values, so this works for any plugin +# whose release ships a checksums.txt (one ` ` per line) +# alongside per-platform archives. Nothing about a specific plugin is hardcoded. +# +# Regenerating index.yaml is intentionally left to the index repo's own +# generator, which reconciles index.yaml from plugins/*.yaml on merge. This only +# edits plugins/.yaml. + +inputs: + index-repo: + required: true + description: "The plugin catalog repository to open the PR against (e.g. `milo-os/cli-plugins`)." + plugin-name: + required: true + description: "The plugin's short name, used to locate its manifest and label the PR (e.g. `ipam`)." + plugin-file: + required: false + default: "" + description: "Path to the plugin manifest within the index repo. Defaults to `plugins/.yaml`." + version: + required: true + description: "The release tag to publish, including the leading `v` (e.g. `v0.2.0`)." + release-repo: + required: false + default: "" + description: "The repository that published the release assets. Defaults to the calling repository." + base-branch: + required: false + default: "main" + description: "Branch in the index repo to base the PR on. Defaults to `main`." + token: + required: true + description: >- + Token with contents:write and pull-requests:write on `index-repo`. Mint it + in the SAME job (e.g. with actions/create-github-app-token) and pass it + here; the caller's built-in GITHUB_TOKEN only reaches its own repository. + +runs: + using: "composite" + steps: + - name: Resolve inputs + id: cfg + shell: bash + 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 (via github.token) is the right credential here. Fails loudly + # if checksums.txt is not among the release assets. + - name: Download checksums.txt from the release + shell: bash + env: + GH_TOKEN: ${{ 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: ${{ inputs.token }} + path: index-repo + + - name: Update plugin manifest + shell: bash + 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: ${{ inputs.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. From 760411582f6c9f38211e0f075a733d8c7912e33d Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Wed, 1 Jul 2026 00:59:15 -0500 Subject: [PATCH 2/2] Link the catalog PR back to the source release Point the generated PR at the release page so reviewers can see what changed in the release the plugin is being bumped to. --- update-plugin-index/action.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/update-plugin-index/action.yml b/update-plugin-index/action.yml index c54f9bf..a8b18b1 100644 --- a/update-plugin-index/action.yml +++ b/update-plugin-index/action.yml @@ -183,8 +183,10 @@ runs: 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 }}`. + Updates the `${{ inputs.plugin-name }}` plugin manifest to release **[${{ inputs.version }}](https://github.com/${{ steps.cfg.outputs.release-repo }}/releases/tag/${{ 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. + + **What's in this release:** https://github.com/${{ steps.cfg.outputs.release-repo }}/releases/tag/${{ inputs.version }}