Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d1989b3
ci(release): post to Slack when a released host needs an upload by hand
imoital Nov 9, 2026
8880820
ci(release): build the reminder's tag check without shell-like expans…
imoital Nov 9, 2026
01c0ef7
ci(release): tag @ZenGrow in the upload reminder and fail without the…
imoital Nov 9, 2026
c7269bd
build: let a host keep its own validate and package scripts
imoital Nov 9, 2026
020778b
feat(render): shared text snippets and code hosts
imoital Nov 9, 2026
75b61be
refactor(render): keep only what code hosts and shared text need
imoital Nov 10, 2026
21d3148
fix(render): catch unfilled tokens and misplaced files in code hosts
imoital Nov 10, 2026
16b1aec
test(render): check the mistakes render and validate must catch
imoital Nov 10, 2026
29bafff
docs(contributing): describe the code host rules and npm test
imoital Nov 10, 2026
1117a8b
fix(validate): allow documented {{…}} in a code host README, and test…
imoital Nov 10, 2026
9cb7a04
fix(render): flag only this tool's leftover tokens, and check snippet…
imoital Nov 10, 2026
c098863
fix(validate): don't count a snippet named only in a code host README…
imoital Nov 10, 2026
6def597
test(render): cover the new rules and copy tracked files with -z
imoital Nov 10, 2026
751868c
docs(contributing): describe which tokens fail the render and the sni…
imoital Nov 10, 2026
c58f982
Merge remote-tracking branch 'origin/build/render-host-kinds' into ci…
imoital Nov 11, 2026
c455731
fix(validate): ask for upload: only on released hosts, and reject lin…
imoital Nov 11, 2026
a94ab8c
ci(release): declare dry_run on the reusable upload reminder, as mirr…
imoital Nov 11, 2026
ec5224d
ci(release): word the upload reminder's notices like mirror.yml's
imoital Nov 11, 2026
cc40395
fix(render): catch tokens with a stray space or capital letter
imoital Nov 11, 2026
61ce4e5
Merge branch 'build/render-host-kinds' into ci/upload-reminder
imoital Nov 11, 2026
f6a970a
Merge remote-tracking branch 'origin/main' into ci/upload-reminder
imoital Nov 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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
108 changes: 108 additions & 0 deletions .github/workflows/upload-reminder.yml
Original file line number Diff line number Diff line change
@@ -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/<host>/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, <host>-v<version>"
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: "<!subteam^S098279QJNA>"
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."
11 changes: 10 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Run `npm run new-host`. It asks for the host id (`platforms/<id>/`, `dist/<id>/`

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/<host>/` as the example. Then run `npm run render` and commit `platforms/<id>/`, `dist/<id>/`, 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/<host>/` as the example. Then run `npm run render` and commit `platforms/<id>/`, `dist/<id>/`, 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

Expand All @@ -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_<HOST>` 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 -- <host> <clone of the mirror> [<release tag>]`, 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/<host>/`, 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/<host>/` 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.
4 changes: 4 additions & 0 deletions platforms/copilot/vars.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: <where>, url: <link>.
# 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
4 changes: 4 additions & 0 deletions scripts/new-host.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: <where>, url: <link>.",
"# 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"),
);
Expand Down
11 changes: 11 additions & 0 deletions scripts/test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]')();
Expand Down
19 changes: 19 additions & 0 deletions scripts/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url|to>, 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: <where> and url: <https:// link>, 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)) {
Expand Down
Loading