diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8827416..e1d97a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -170,7 +170,8 @@ jobs: gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TAG" --notes-file - # Steps that publish a host somewhere else go here, selected by component, - # for example: if: matrix.component == 'openai'. Mirror repos are the mirror job below. + # for example: if: matrix.component == 'openai'. Mirror repos are the mirror + # job below; uploads done by hand get a Slack reminder from upload-reminder. - name: Write the summary run: | @@ -198,3 +199,20 @@ jobs: host: ${{ matrix.component }} tag: ${{ matrix.tag }} secrets: inherit + + # Each released host with upload: in its vars.yaml gets a reminder in + # #proj-integrations to upload it by hand; for the others this is a no-op. + upload-reminder: + name: Upload reminder ${{ matrix.component }} + needs: [detect, release] + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.detect.outputs.packages) }} + uses: ./.github/workflows/upload-reminder.yml + with: + host: ${{ matrix.component }} + tag: ${{ matrix.tag }} + secrets: inherit diff --git a/.github/workflows/upload-reminder.yml b/.github/workflows/upload-reminder.yml new file mode 100644 index 0000000..bbfdaba --- /dev/null +++ b/.github/workflows/upload-reminder.yml @@ -0,0 +1,108 @@ +name: Upload reminder + +# Posts to #proj-integrations that a release has to be uploaded by hand, for a +# host with upload: in platforms//vars.yaml (for example a marketplace +# that reviews each version). release.yml calls it for each released host. Run +# it by hand to send a reminder again; a manual run is a dry run unless you untick it. + +on: + workflow_call: + inputs: + host: + type: string + required: true + tag: + description: "The release, -v" + type: string + required: true + dry_run: + type: boolean + default: false + workflow_dispatch: + inputs: + host: + description: "Host, for example openai" + type: string + required: true + tag: + description: "The release, for example openai-v0.1.9" + type: string + required: true + dry_run: + description: "Show the message without posting it" + type: boolean + default: true + +permissions: {} + +jobs: + remind: + name: Upload reminder ${{ inputs.host }} + runs-on: ubuntu-latest + # The release environment deploys only from main and holds the webhook. + environment: release + permissions: + contents: read + env: + HOST: ${{ inputs.host }} + TAG: ${{ inputs.tag }} + DRY_RUN: ${{ inputs.dry_run }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ inputs.tag }} + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" + cache: npm + - run: npm ci + + - name: Post the reminder + env: + # A Slack incoming webhook for #proj-integrations. + WEBHOOK: ${{ secrets.SLACK_WEBHOOK_INTEGRATIONS }} + # The @ZenGrow user group, which owns the integrations. + MENTION: "" + run: | + set -euo pipefail + upload=$(node --input-type=module -e ' + import { vars } from "./scripts/lib.mjs"; + const { HOST, TAG } = process.env; + const v = vars(HOST); + if (HOST + "-v" + v.version !== TAG) { + // \x27 is an apostrophe: a literal one would end this shell-quoted script. + console.error("::error::" + TAG + " isn\x27t a " + HOST + " release: at that tag, " + HOST + " is at " + v.version + "."); + process.exit(1); + } + console.log(JSON.stringify(v.upload ?? false));') + if [ "$upload" = false ]; then + echo "::notice::$HOST has no upload to do by hand (upload: in platforms/$HOST/vars.yaml is false or missing)." + exit 0 + fi + + repo="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" + payload=$(jq -n --argjson upload "$upload" --arg mention "$MENTION" --arg host "$HOST" --arg tag "$TAG" --arg repo "$repo" '{ + text: ("\($mention) :package: *\($tag)* is released and needs an upload to *\($upload.to)*.\n" + + "Files: <\($repo)/tree/\($tag)/dist/\($host)|dist/\($host) at \($tag)>" + + " · Upload: <\($upload.url)|\($upload.to)>" + + " · Release: <\($repo)/releases/tag/\($tag)|\($tag)>") + }') + { + echo "## $HOST upload reminder" + jq -r .text <<<"$payload" + } >>"$GITHUB_STEP_SUMMARY" + + if [ "$DRY_RUN" = true ]; then + echo "Dry run: #proj-integrations would get this message." + jq . <<<"$payload" + exit 0 + fi + # Fail rather than warn: the release is already out, and a red job is + # harder to miss than a reminder nobody received. + if [ -z "$WEBHOOK" ]; then + echo "::error::No SLACK_WEBHOOK_INTEGRATIONS secret in the release environment, so the reminder wasn't posted to Slack. It's in the job summary: upload $TAG to $(jq -r .to <<<"$upload")." + exit 1 + fi + curl -fsS -X POST -H 'Content-type: application/json' --data "$payload" "$WEBHOOK" >/dev/null + echo "::notice::Posted the $TAG upload reminder to #proj-integrations." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a18f613..bc3b5b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,7 +68,7 @@ Run `npm run new-host`. It asks for the host id (`platforms//`, `dist//` It also adds the host to `release-please-config.json` and `.release-please-manifest.json` at `0.1.0`. Answers can also be piped in, one per line. -What only a person can decide is marked `TODO(new-host)`, and `npm run validate` fails until it's done: the install and sign-in steps, where the agent can write files, and the plugin files. Each host reads its own layout (Claude: `.claude-plugin/plugin.json` and `.mcp.json`; Cursor: `.cursor-plugin/plugin.json`), so move the templates to those paths, add the fields the host needs, update the manifest path in `release-please-config.json`, and add a marketplace catalog at the top of the repo if the host installs from a subfolder. Use an existing `platforms//` as the example. Then run `npm run render` and commit `platforms//`, `dist//`, the release-please files and `README.md` together. The render adds the host to the "Supported hosts" list in `README.md`, so don't add it by hand. +What only a person can decide is marked `TODO(new-host)`, and `npm run validate` fails until it's done: the install and sign-in steps, where the agent can write files, whether releases are uploaded by hand (`upload:`), and the plugin files. Each host reads its own layout (Claude: `.claude-plugin/plugin.json` and `.mcp.json`; Cursor: `.cursor-plugin/plugin.json`), so move the templates to those paths, add the fields the host needs, update the manifest path in `release-please-config.json`, and add a marketplace catalog at the top of the repo if the host installs from a subfolder. Use an existing `platforms//` as the example. Then run `npm run render` and commit `platforms//`, `dist//`, the release-please files and `README.md` together. The render adds the host to the "Supported hosts" list in `README.md`, so don't add it by hand. ## Commits and releases @@ -84,3 +84,12 @@ Some installs name a repo of their own instead of a folder here: `copilot plugin - It pushes with the deploy key in the `MIRROR_KEY_` secret of the `release` environment. The key, the mirror's branch protection and its settings are in [iac-bootstrap](https://github.com/ZenRows/iac-bootstrap). - To see what the mirror would get: `npm run mirror -- []`, then `git diff` in the clone. To publish by hand (for example after fixing a failed run), run the Mirror workflow from Actions with the host and the release tag; it's a dry run unless you untick it. With no tag it publishes `main`'s `dist//`, names the commit in the README banner and tags nothing. - Manual runs, dry runs included, start from `main`: the job uses the `release` environment, which only deploys from `main`. + +### Uploads by hand + +Some hosts take each release only as an upload that someone does by hand, for example a marketplace that reviews every version. Every host's `vars.yaml` says which: + +- `upload: false`: installs read this repo or the host's mirror, so nothing is uploaded. +- `upload:` with `to:` (where, for example `Cursor Marketplace`) and `url:` (an `https://` link to the page you upload on): each release of the host posts a reminder to #proj-integrations naming the release, its files in `dist//` at the tag, and that link. + +`npm run validate` fails if a released host has no `upload:`. A host with no release-please entry (a code host not released from here) never posts a reminder, so it leaves `upload:` out, or writes `false`. `.github/workflows/upload-reminder.yml` posts through a Slack incoming webhook in the `SLACK_WEBHOOK_INTEGRATIONS` secret of the `release` environment and tags @ZenGrow. Without the secret, the job fails, so a missed reminder shows as a red run; the message is still in its job summary. To send a reminder again, run the Upload reminder workflow from Actions, on `main`, with the host and the release tag; it's a dry run unless you untick it. diff --git a/platforms/copilot/vars.yaml b/platforms/copilot/vars.yaml index 4ef2ba9..ad61669 100644 --- a/platforms/copilot/vars.yaml +++ b/platforms/copilot/vars.yaml @@ -9,3 +9,7 @@ skill_prefix: "" # Published to this repo too on each release, for installs that name it # (copilot plugin marketplace add ZenRows/copilot-zenrows). See CONTRIBUTING.md. mirror: ZenRows/copilot-zenrows +# Where each release is uploaded by hand, if anywhere: to: , url: . +# The release workflow posts it to #proj-integrations. false: installs read +# this repo or its mirror, so nothing is uploaded. See CONTRIBUTING.md. +upload: false diff --git a/scripts/new-host.mjs b/scripts/new-host.mjs index 3608378..748ab4f 100644 --- a/scripts/new-host.mjs +++ b/scripts/new-host.mjs @@ -65,6 +65,10 @@ files.set( "# scrape-webpage: zenrows-scrape", "# To leave a shared skill out on this host, list it here, for example:", "# skip_skills: [using-zenrows]", + "# Where each release is uploaded by hand, if anywhere: to: , url: .", + "# The release workflow posts it to #proj-integrations. false: installs read", + "# this repo or its mirror, so nothing is uploaded. See CONTRIBUTING.md.", + 'upload: "TODO(new-host): false, or to: and url:"', "", ].join("\n"), ); diff --git a/scripts/test.mjs b/scripts/test.mjs index bf0be02..3ea8b3a 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -102,6 +102,17 @@ test("an unknown variable fails", fails({ ...piece, "platforms/piece/src/index.t test("a code host with no README fails", fails({ ...piece, "platforms/piece/README.md": null }, "a code host needs platforms/piece/README.md")); test("a code host with a skills/ folder fails", fails({ ...piece, "platforms/piece/skills/scrape/SKILL.md": "x\n" }, "a code host (kind: code) ships no skills")); test("a skill reference in a code host fails", fails({ ...piece, "platforms/piece/src/index.ts": "export const s = \"{{skill:batch}}\";\n" }, "a code host (kind: code) ships no skills")); +test("an unreleased code host needs no upload:", passes(piece)); +test("an unreleased code host with an upload: to and url fails", fails({ ...piece, "platforms/piece/vars.yaml": "host_full: Piece\nkind: code\nupload:\n to: npm\n url: https://www.npmjs.com/\n" }, "has no release, so an upload reminder can never fire")); +test("a released host without upload: fails", fails({ + "platforms/copilot/vars.yaml": readFileSync(join(root, "platforms/copilot/vars.yaml"), "utf8").replace(/^upload:.*\n/m, ""), +}, "copilot: vars.yaml has no upload:")); +test("an upload: link Slack can't show fails", () => { + const vars = readFileSync(join(root, "platforms/copilot/vars.yaml"), "utf8").replace(/^upload:.*\n/m, ""); + for (const upload of ['upload:\n to: X\n url: "https://a b"\n', 'upload:\n to: X\n url: "https://a|b"\n', 'upload:\n to: "A | B"\n url: https://example.com\n']) { + fails({ "platforms/copilot/vars.yaml": vars + upload }, "copilot: vars.yaml upload: must be false")(); + } +}); test("an unknown kind fails", () => { fails({ ...piece, "platforms/piece/vars.yaml": "host_full: Piece\nkind: Code\n" }, 'platforms/piece/vars.yaml: kind is "Code"')(); fails({ ...piece, "platforms/piece/vars.yaml": "host_full: Piece\nkind: [code]\n" }, 'platforms/piece/vars.yaml: kind is ["code"]')(); diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 8aa12b4..325a206 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -188,6 +188,25 @@ for (const host of hosts()) { } } + // Every released host says whether its releases are uploaded somewhere by + // hand, so the release workflow knows whether to post a reminder. A host with + // no release-please entry (a code host not released from here) never gets a + // tag, so a reminder could never fire for it. + const upload = values.upload; + if (values.version === undefined) { + if (upload !== undefined && upload !== false) { + errors.push(`${host}: vars.yaml sets upload:, but ${host} has no release, so an upload reminder can never fire`); + } + } else if (upload === undefined) { + errors.push(`${host}: vars.yaml has no upload: (false, or to: and url:; see CONTRIBUTING.md)`); + } else if ( + upload !== false && + // The reminder links them as Slack's , which a space, <, > or | would cut short. + !(typeof upload?.to === "string" && upload.to.trim() && !/[<>|]/.test(upload.to) && typeof upload.url === "string" && /^https:\/\/[^\s<>|]+$/.test(upload.url)) + ) { + errors.push(`${host}: vars.yaml upload: must be false, or to: and url: , without spaces in the link or <, > or | in either`); + } + // A whole-file override stops receiving fixes to the shared file it copied. const lock = readLock(host); for (const [shared, hash] of Object.entries(lock)) {