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..a8b18b1 --- /dev/null +++ b/update-plugin-index/action.yml @@ -0,0 +1,192 @@ +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 }}](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 }}