Skip to content

Commit ae6d2f2

Browse files
committed
ci: automate releases
Publishing this app is manual today, and the repo drifted from what Zapier runs: #99 had to reconstruct 4.10.0 through 4.14.0 out of the published app and back into git. Bump the minor version and write the changelog entry in the same PR as the code. CI fails the PR when code that reaches Zapier changes without that. Merging uploads that version to Zapier and promotes it, so new Zaps get it. Existing Zaps keep the version they were built on, because nothing migrates them. The release workflow asks Zapier which versions it holds rather than inferring from the push, so a release that never arrived is retried on the next push to master, or from the Actions tab. That is also why there is one workflow here and not three. Check the promote step. It passes --yes, without which the CLI waits on a prompt nobody can answer on a runner, and that flag also accepts Zapier's non-blocking pre-check warnings. The repo moves to Node 22, because the Zapier CLI that CI installs pulls dependencies that refuse to install below Node 20. Zapier still runs this app on Node 18, and will until we move to core 17. Towards INF-3414
1 parent 9b0bdf7 commit ae6d2f2

9 files changed

Lines changed: 299 additions & 12 deletions

File tree

.github/workflows/nodejs.yml

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ jobs:
88

99
strategy:
1010
matrix:
11-
node-version: [18.19.1]
11+
node-version: [22.23.2]
1212

1313
steps:
14-
- uses: actions/checkout@v1
14+
- uses: actions/checkout@v7
15+
with:
16+
persist-credentials: false
1517
- name: Use Node.js ${{ matrix.node-version }}
16-
uses: actions/setup-node@v1
18+
uses: actions/setup-node@v7
1719
with:
1820
node-version: ${{ matrix.node-version }}
1921
- name: yarn, build, and test
@@ -25,3 +27,62 @@ jobs:
2527
NODE_ENV: test
2628
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
2729
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
30+
- name: Install the Zapier CLI
31+
run: |
32+
npm install --no-save --prefix "$RUNNER_TEMP/cli" zapier-platform-cli@19.1.0
33+
echo "$RUNNER_TEMP/cli/node_modules/.bin" >> "$GITHUB_PATH"
34+
- name: Validate the Zapier schema
35+
run: |
36+
yarn zapier-build
37+
zapier-platform validate --without-style
38+
39+
version:
40+
name: Version bump
41+
if: github.ref_name != 'master'
42+
runs-on: ubuntu-latest
43+
timeout-minutes: 5
44+
steps:
45+
- uses: actions/checkout@v7
46+
with:
47+
fetch-depth: 0
48+
persist-credentials: false
49+
50+
- uses: actions/setup-node@v7
51+
with:
52+
node-version-file: .nvmrc
53+
54+
- name: Require a version bump for shipped changes
55+
run: |
56+
git fetch --no-tags origin master
57+
base=$(git merge-base origin/master HEAD)
58+
59+
changed=$(git diff --name-only "$base..HEAD" -- src index.js tsconfig.json ':(exclude)src/test')
60+
git show "$base:package.json" > "$RUNNER_TEMP/base-package.json"
61+
deps=$(node scripts/dependencies-changed.js "$RUNNER_TEMP/base-package.json" package.json)
62+
63+
if [ -z "$changed" ] && [ "$deps" = "false" ]; then
64+
echo "nothing here reaches Zapier, no version bump needed"
65+
exit 0
66+
fi
67+
68+
git show origin/master:package.json > "$RUNNER_TEMP/master-package.json"
69+
master_version=$(node -p "JSON.parse(require('fs').readFileSync(process.env.RUNNER_TEMP + '/master-package.json', 'utf8')).version")
70+
head_version=$(node -p "require('./package.json').version")
71+
72+
if [ "$master_version" = "$head_version" ]; then
73+
echo "This branch changes code that reaches Zapier but leaves the version at $head_version." >&2
74+
echo "Raise the minor version in package.json and add a matching '## <version>' entry to CHANGELOG.md." >&2
75+
exit 1
76+
fi
77+
78+
if [ "$(printf '%s\n%s\n' "$master_version" "$head_version" | sort -V | tail -1)" != "$head_version" ]; then
79+
echo "Version $head_version is behind master, which is at $master_version. Rebase and bump again." >&2
80+
exit 1
81+
fi
82+
83+
if ! grep -q "^## ${head_version}\$" CHANGELOG.md; then
84+
echo "CHANGELOG.md has no '## ${head_version}' entry, and Zapier reads that entry when the version is promoted." >&2
85+
exit 1
86+
fi
87+
88+
echo "version $master_version -> $head_version"

.github/workflows/release.yml

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
name: Release
2+
3+
on:
4+
workflow_run:
5+
workflows: ["CI"]
6+
types: [completed]
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: release
14+
cancel-in-progress: false
15+
16+
jobs:
17+
release:
18+
if: >-
19+
(github.event_name == 'workflow_dispatch' && github.ref_name == 'master') ||
20+
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' &&
21+
github.event.workflow_run.head_branch == 'master')
22+
runs-on: ubuntu-latest
23+
timeout-minutes: 45
24+
outputs:
25+
action: ${{ steps.state.outputs.action }}
26+
steps:
27+
- uses: actions/checkout@v7
28+
with:
29+
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
30+
fetch-depth: 0
31+
persist-credentials: false
32+
33+
- uses: actions/setup-node@v7
34+
with:
35+
node-version-file: .nvmrc
36+
cache: yarn
37+
38+
- name: Install
39+
run: yarn install --frozen-lockfile
40+
41+
- name: Install the Zapier CLI
42+
run: |
43+
npm install --no-save --prefix "$RUNNER_TEMP/cli" zapier-platform-cli@19.1.0
44+
echo "$RUNNER_TEMP/cli/node_modules/.bin" >> "$GITHUB_PATH"
45+
46+
- name: Ask Zapier what it holds
47+
id: state
48+
env:
49+
ZAPIER_DEPLOY_KEY: ${{ secrets.ZAPIER_DEPLOY_KEY }}
50+
run: |
51+
zapier-platform versions --format=raw > "$RUNNER_TEMP/versions.json"
52+
zapier-platform jobs --format=raw > "$RUNNER_TEMP/jobs.json"
53+
jq -e . "$RUNNER_TEMP/jobs.json" >/dev/null 2>&1 || echo "[]" > "$RUNNER_TEMP/jobs.json"
54+
node scripts/release-state.js "$RUNNER_TEMP/versions.json" "$RUNNER_TEMP/jobs.json" >> "$GITHUB_OUTPUT"
55+
56+
- name: Upload the version
57+
if: steps.state.outputs.action == 'publish'
58+
env:
59+
ZAPIER_DEPLOY_KEY: ${{ secrets.ZAPIER_DEPLOY_KEY }}
60+
run: yarn zapier-push
61+
62+
- name: Promote
63+
if: steps.state.outputs.action == 'publish' || steps.state.outputs.action == 'promote'
64+
env:
65+
ZAPIER_DEPLOY_KEY: ${{ secrets.ZAPIER_DEPLOY_KEY }}
66+
VERSION: ${{ steps.state.outputs.version }}
67+
run: |
68+
zapier-platform promote --yes "$VERSION"
69+
70+
for attempt in $(seq 1 60); do
71+
stage=$(zapier-platform jobs --format=raw 2>/dev/null \
72+
| jq -r --arg v "$VERSION" '[.[] | select(.job_kind == "promote" and .version_to == $v)] | last | .job_stage // empty' 2>/dev/null || true)
73+
echo "attempt $attempt: ${stage:-no job yet}"
74+
case "$stage" in
75+
complete) exit 0 ;;
76+
errored|aborted|paused) echo "promotion ended as $stage" >&2; exit 1 ;;
77+
esac
78+
sleep 20
79+
done
80+
81+
echo "promotion did not finish within 20 minutes" >&2
82+
exit 1

.nvmrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
18.19.1
1+
22.23.2

README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ yarn global add zapier-platform-cli
1111
yarn install
1212
```
1313

14-
Use Node 18 or newer, matching `package.json`. If you're running a newer version and don't have `nvm` set up, you can run `yarn install --ignore-engines` to disable the Node version check.
14+
Use Node 22 or newer, matching `package.json`. If you're running a newer version and don't have `nvm` set up, you can run `yarn install --ignore-engines` to disable the Node version check.
1515

1616
## Developing
1717

@@ -22,14 +22,17 @@ For testing, save your envvars to `.env`. `.env.default` has the required variab
2222

2323
### Deployment
2424

25-
Prerequisites:
25+
A release starts in your own pull request. Bump the minor version in `package.json` and add a
26+
matching `## <version>` entry to `CHANGELOG.md` alongside the code. CI fails the pull request if
27+
code that reaches Zapier changes without both.
2628

27-
- Make sure you have updated the version number in `package.json`.
28-
- If updating Linear's app, you'll need to have access to Linear's Zapier account and generate a deploy key in `Settings > Deploy Keys`. You can then authenticate with the key using `zapier login --sso`.
29+
Merging to master uploads that version to Zapier and promotes it, so new Zaps get it.
2930

30-
You can deploy the app to Zapier with `yarn zapier-push`. This will also run `yarn zapier-validate` before deploying.
31+
The workflow asks Zapier which versions it holds instead of assuming, so a release that never
32+
arrived is retried on the next push to master. Run it from the Actions tab when you want it sooner.
3133

32-
After deploying, you'll need to manually promote the version to 'public' in Zapier's dashboard under `App > Manage > Versions`.
34+
Two things the release does not do. It does not move existing Zaps to the new version, so they
35+
keep running the version they were built on. It does not deprecate old versions.
3336

3437
## Forking
3538

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
1111
"zapier-push": "npm run zapier-validate && zapier-platform push",
1212
"prepare": "npm run zapier-build && (husky install || true)",
1313
"pretest": "npm run zapier-build",
14-
"test": "jest --passWithNoTests src/test",
14+
"test": "jest --passWithNoTests src/test scripts",
1515
"prettier": "npx prettier --write ."
1616
},
1717
"engines": {
18-
"node": ">=18.0.0",
18+
"node": ">=22.0.0",
1919
"npm": ">=10.0.0"
2020
},
2121
"dependencies": {

scripts/dependencies-changed.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
const fs = require("fs");
2+
3+
const canonical = (dependencies = {}) =>
4+
JSON.stringify(
5+
Object.keys(dependencies)
6+
.sort()
7+
.map((name) => [name, dependencies[name]])
8+
);
9+
10+
const dependenciesChanged = (oldManifest, newManifest) =>
11+
canonical(oldManifest.dependencies) !== canonical(newManifest.dependencies);
12+
13+
const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
14+
15+
module.exports = { dependenciesChanged };
16+
17+
if (require.main === module) {
18+
const [oldFile, newFile] = process.argv.slice(2);
19+
process.stdout.write(String(dependenciesChanged(read(oldFile), read(newFile))));
20+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
const { dependenciesChanged } = require("./dependencies-changed");
2+
3+
const manifest = (dependencies) => ({ name: "linear-zapier", version: "4.15.0", dependencies });
4+
5+
describe("dependenciesChanged", () => {
6+
it("sees a version change", () => {
7+
expect(dependenciesChanged(manifest({ lodash: "4.17.21" }), manifest({ lodash: "4.17.22" }))).toBe(true);
8+
});
9+
10+
it("sees an added and a removed dependency", () => {
11+
expect(dependenciesChanged(manifest({ lodash: "4.17.21" }), manifest({ lodash: "4.17.21", ky: "1.0.0" }))).toBe(
12+
true
13+
);
14+
expect(dependenciesChanged(manifest({ lodash: "4.17.21", ky: "1.0.0" }), manifest({ lodash: "4.17.21" }))).toBe(
15+
true
16+
);
17+
});
18+
19+
it("ignores key order", () => {
20+
expect(
21+
dependenciesChanged(manifest({ lodash: "4.17.21", ky: "1.0.0" }), manifest({ ky: "1.0.0", lodash: "4.17.21" }))
22+
).toBe(false);
23+
});
24+
25+
it("ignores everything outside the dependencies block", () => {
26+
const before = { ...manifest({ lodash: "4.17.21" }), devDependencies: { jest: "29.7.0" }, scripts: { test: "a" } };
27+
const after = { ...manifest({ lodash: "4.17.21" }), devDependencies: { jest: "30.0.0" }, scripts: { test: "b" } };
28+
expect(dependenciesChanged(before, after)).toBe(false);
29+
});
30+
31+
it("treats a missing dependencies block as empty", () => {
32+
expect(dependenciesChanged({ name: "x" }, { name: "x" })).toBe(false);
33+
expect(dependenciesChanged({ name: "x" }, manifest({ lodash: "4.17.21" }))).toBe(true);
34+
});
35+
});

scripts/release-state.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
const fs = require("fs");
2+
const path = require("path");
3+
4+
const ROOT = path.join(__dirname, "..");
5+
const PACKAGE_FILE = path.join(ROOT, "package.json");
6+
const FAILED_STAGES = ["aborted", "errored", "paused"];
7+
8+
const releaseState = (version, versions, jobs) => {
9+
if (!/^\d+\.\d+\.\d+$/.test(version)) {
10+
return { action: "fail", reason: `unexpected version format: ${version}` };
11+
}
12+
13+
const row = versions.find((v) => v.version === version);
14+
if (!row) {
15+
return { action: "publish" };
16+
}
17+
if (row.state === "deprecated") {
18+
return { action: "fail", reason: `${version} is deprecated on Zapier.` };
19+
}
20+
21+
const promotions = jobs.filter((job) => job.job_kind === "promote" && job.version_to === version);
22+
if (promotions.some((job) => job.job_stage === "complete")) {
23+
return { action: "none" };
24+
}
25+
26+
const failed = promotions.find((job) => FAILED_STAGES.includes(job.job_stage));
27+
if (failed) {
28+
return { action: "promote", reason: `${version} promotion ended as ${failed.job_stage}.` };
29+
}
30+
31+
return { action: "none" };
32+
};
33+
34+
const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
35+
36+
module.exports = { releaseState };
37+
38+
if (require.main === module) {
39+
const [versionsFile, jobsFile] = process.argv.slice(2);
40+
const { version } = read(PACKAGE_FILE);
41+
const state = releaseState(version, read(versionsFile), read(jobsFile));
42+
43+
if (state.reason) {
44+
process.stderr.write(`${state.reason}\n`);
45+
}
46+
if (state.action === "fail") {
47+
process.exit(1);
48+
}
49+
process.stdout.write(`action=${state.action}\nversion=${version}\n`);
50+
}

scripts/release-state.test.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
const { releaseState } = require("./release-state");
2+
3+
describe("releaseState", () => {
4+
it("publishes a version Zapier does not hold", () => {
5+
expect(releaseState("4.16.0", [{ version: "4.15.0", state: "live" }], [])).toEqual({ action: "publish" });
6+
});
7+
8+
it("does nothing when the version is live and no jobs exist", () => {
9+
expect(releaseState("4.16.0", [{ version: "4.16.0", state: "live" }], [])).toEqual({ action: "none" });
10+
});
11+
12+
it("fails on a deprecated version", () => {
13+
const state = releaseState("4.16.0", [{ version: "4.16.0", state: "deprecated" }], []);
14+
expect(state.action).toBe("fail");
15+
expect(state.reason).toMatch(/deprecated/);
16+
});
17+
18+
it("promotes again when the only promotion errored", () => {
19+
const jobs = [{ job_kind: "promote", version_to: "4.16.0", job_stage: "errored" }];
20+
const state = releaseState("4.16.0", [{ version: "4.16.0", state: "live" }], jobs);
21+
expect(state.action).toBe("promote");
22+
expect(state.reason).toMatch(/errored/);
23+
});
24+
25+
it("does nothing when a later promotion completed", () => {
26+
const jobs = [
27+
{ job_kind: "promote", version_to: "4.16.0", job_stage: "errored" },
28+
{ job_kind: "promote", version_to: "4.16.0", job_stage: "complete" },
29+
];
30+
expect(releaseState("4.16.0", [{ version: "4.16.0", state: "live" }], jobs)).toEqual({ action: "none" });
31+
});
32+
33+
it("fails on a version it does not understand", () => {
34+
expect(releaseState("4.16", [], []).action).toBe("fail");
35+
});
36+
});

0 commit comments

Comments
 (0)