Skip to content

Commit b9a7e83

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 b9a7e83

11 files changed

Lines changed: 558 additions & 17 deletions

File tree

.github/workflows/nodejs.yml

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,65 @@ name: CI
22

33
on: [push]
44

5+
permissions:
6+
contents: read
7+
58
jobs:
69
build:
710
runs-on: ubuntu-latest
811

9-
strategy:
10-
matrix:
11-
node-version: [18.19.1]
12-
1312
steps:
14-
- uses: actions/checkout@v1
15-
- name: Use Node.js ${{ matrix.node-version }}
16-
uses: actions/setup-node@v1
13+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1714
with:
18-
node-version: ${{ matrix.node-version }}
15+
persist-credentials: false
16+
- name: Use Node.js
17+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
18+
with:
19+
node-version-file: .nvmrc
20+
cache: yarn
1921
- name: yarn, build, and test
2022
run: |
21-
yarn
23+
yarn install --frozen-lockfile
2224
yarn test
2325
env:
2426
CI: true
2527
NODE_ENV: test
2628
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
2729
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
30+
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
31+
with:
32+
path: ${{ runner.temp }}/cli
33+
key: zapier-cli-19.1.0-${{ runner.os }}
34+
- name: Install the Zapier CLI
35+
run: |
36+
if [ ! -x "$RUNNER_TEMP/cli/node_modules/.bin/zapier-platform" ]; then
37+
npm install --no-save --prefix "$RUNNER_TEMP/cli" zapier-platform-cli@19.1.0
38+
fi
39+
echo "$RUNNER_TEMP/cli/node_modules/.bin" >> "$GITHUB_PATH"
40+
- name: Validate the Zapier schema
41+
run: |
42+
yarn zapier-build
43+
zapier-platform validate --without-style
44+
45+
version:
46+
name: Version bump
47+
if: github.ref_name != 'master'
48+
runs-on: ubuntu-latest
49+
timeout-minutes: 5
50+
steps:
51+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
52+
with:
53+
fetch-depth: 0
54+
persist-credentials: false
55+
56+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
57+
with:
58+
node-version-file: .nvmrc
59+
60+
- name: Require a version bump for shipped changes
61+
run: |
62+
git fetch --no-tags origin master:refs/remotes/origin/master
63+
base=$(git merge-base origin/master HEAD)
64+
git diff --name-only "$base..HEAD" > "$RUNNER_TEMP/changed.txt"
65+
git show origin/master:package.json > "$RUNNER_TEMP/master-package.json"
66+
node scripts/versionGate.js "$RUNNER_TEMP/changed.txt" "$RUNNER_TEMP/master-package.json"

.github/workflows/release.yml

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
name: Release
2+
3+
on:
4+
workflow_run:
5+
workflows: ["CI"]
6+
types: [completed]
7+
branches: [master]
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
concurrency:
14+
group: release
15+
cancel-in-progress: false
16+
17+
jobs:
18+
release:
19+
if: >-
20+
github.repository == 'linear/linear-zapier' &&
21+
((github.event_name == 'workflow_dispatch' && github.ref_name == 'master') ||
22+
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' &&
23+
github.event.workflow_run.head_branch == 'master'))
24+
runs-on: ubuntu-latest
25+
timeout-minutes: 45
26+
steps:
27+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
34+
with:
35+
node-version-file: .nvmrc
36+
cache: yarn
37+
38+
- name: Install
39+
run: yarn install --frozen-lockfile
40+
41+
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
42+
with:
43+
path: ${{ runner.temp }}/cli
44+
key: zapier-cli-19.1.0-${{ runner.os }}
45+
46+
- name: Install the Zapier CLI
47+
run: |
48+
if [ ! -x "$RUNNER_TEMP/cli/node_modules/.bin/zapier-platform" ]; then
49+
npm install --no-save --prefix "$RUNNER_TEMP/cli" zapier-platform-cli@19.1.0
50+
fi
51+
echo "$RUNNER_TEMP/cli/node_modules/.bin" >> "$GITHUB_PATH"
52+
53+
- name: Ask Zapier what it holds
54+
id: state
55+
env:
56+
ZAPIER_DEPLOY_KEY: ${{ secrets.ZAPIER_DEPLOY_KEY }}
57+
run: |
58+
zapier-platform versions --all --format=raw > "$RUNNER_TEMP/versions.json"
59+
zapier-platform jobs --format=raw > "$RUNNER_TEMP/jobs.json"
60+
node scripts/releaseState.js "$RUNNER_TEMP/versions.json" "$RUNNER_TEMP/jobs.json" >> "$GITHUB_OUTPUT"
61+
62+
- name: Upload the version
63+
if: steps.state.outputs.action == 'publish'
64+
env:
65+
ZAPIER_DEPLOY_KEY: ${{ secrets.ZAPIER_DEPLOY_KEY }}
66+
run: yarn zapier-push
67+
68+
- name: Promote
69+
if: steps.state.outputs.action == 'publish' || steps.state.outputs.action == 'promote'
70+
env:
71+
ZAPIER_DEPLOY_KEY: ${{ secrets.ZAPIER_DEPLOY_KEY }}
72+
VERSION: ${{ steps.state.outputs.version }}
73+
run: |
74+
zapier-platform jobs --format=raw > "$RUNNER_TEMP/jobs-before.json"
75+
zapier-platform promote --yes "$VERSION"
76+
77+
for attempt in $(seq 1 60); do
78+
if ! zapier-platform jobs --format=raw > "$RUNNER_TEMP/poll-jobs.json"; then
79+
echo "attempt $attempt: could not read jobs, retrying"
80+
sleep 20
81+
continue
82+
fi
83+
84+
stage=$(node scripts/releaseState.js promotion-stage "$VERSION" "$RUNNER_TEMP/poll-jobs.json" "$RUNNER_TEMP/jobs-before.json") || {
85+
echo "attempt $attempt: could not read promotion state, retrying"
86+
sleep 20
87+
continue
88+
}
89+
90+
echo "attempt $attempt: $stage"
91+
case "$stage" in
92+
complete) exit 0 ;;
93+
failed|paused) echo "promotion ended as $stage" >&2; exit 1 ;;
94+
esac
95+
sleep 20
96+
done
97+
98+
echo "promotion did not finish within 20 minutes" >&2
99+
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: 13 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,21 @@ 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.
36+
37+
One case the check cannot see: a `yarn upgrade` that only re-resolves a transitive production
38+
dependency changes what Zapier runs without touching `src/` or the `dependencies` block. Bump the
39+
version by hand when you do that.
3340

3441
## Forking
3542

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
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 src/test scripts",
1515
"prettier": "npx prettier --write ."
1616
},
1717
"engines": {

scripts/releaseState.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
const fs = require("fs");
2+
const path = require("path");
3+
4+
const { isVersion, compareVersions } = require("./version");
5+
6+
const ROOT = path.join(__dirname, "..");
7+
const PACKAGE_FILE = path.join(ROOT, "package.json");
8+
const PROMOTION_OUTCOMES = { errored: "failed", aborted: "failed", paused: "paused" };
9+
10+
const promotionStage = (jobs, version, knownJobIds = []) => {
11+
const known = new Set(knownJobIds);
12+
const promotions = jobs.filter(
13+
(job) => job.job_kind === "promote" && job.version_to === version && !known.has(job.job_id)
14+
);
15+
if (promotions.some((job) => job.job_stage === "complete")) {
16+
return "complete";
17+
}
18+
const ended = promotions.findLast((job) => PROMOTION_OUTCOMES[job.job_stage]);
19+
return ended ? PROMOTION_OUTCOMES[ended.job_stage] : "pending";
20+
};
21+
22+
const highestVersion = (versions) =>
23+
versions
24+
.map((row) => row.version)
25+
.filter(isVersion)
26+
.sort(compareVersions)
27+
.pop();
28+
29+
const releaseState = (version, versions, jobs) => {
30+
if (!isVersion(version)) {
31+
return { action: "fail", reason: `unexpected version format: ${version}` };
32+
}
33+
34+
const highest = highestVersion(versions);
35+
if (highest && compareVersions(version, highest) < 0) {
36+
return {
37+
action: "fail",
38+
reason: `${version} is below ${highest}, which Zapier already holds, so releasing it would move Zapier backwards.`,
39+
};
40+
}
41+
42+
const row = versions.find((v) => v.version === version);
43+
if (!row) {
44+
return { action: "publish" };
45+
}
46+
if (row.state === "deprecated") {
47+
return { action: "fail", reason: `${version} is deprecated on Zapier.` };
48+
}
49+
50+
const stage = promotionStage(jobs, version);
51+
if (stage === "failed") {
52+
return { action: "promote", reason: `${version} promotion ended as failed.` };
53+
}
54+
if (stage === "paused") {
55+
return {
56+
action: "fail",
57+
reason: `A person paused the ${version} promotion on Zapier's side, so it is not retried automatically.`,
58+
};
59+
}
60+
return { action: "none" };
61+
};
62+
63+
const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
64+
65+
module.exports = { promotionStage, releaseState };
66+
67+
if (require.main === module) {
68+
const args = process.argv.slice(2);
69+
70+
if (args[0] === "promotion-stage") {
71+
const [, version, jobsFile, knownJobsFile] = args;
72+
const knownJobIds = knownJobsFile ? read(knownJobsFile).map((job) => job.job_id) : [];
73+
process.stdout.write(`${promotionStage(read(jobsFile), version, knownJobIds)}\n`);
74+
} else {
75+
const [versionsFile, jobsFile] = args;
76+
const { version } = read(PACKAGE_FILE);
77+
const state = releaseState(version, read(versionsFile), read(jobsFile));
78+
79+
if (state.reason) {
80+
process.stderr.write(`${state.reason}\n`);
81+
}
82+
if (state.action === "fail") {
83+
process.exit(1);
84+
}
85+
process.stdout.write(`action=${state.action}\nversion=${version}\n`);
86+
}
87+
}

0 commit comments

Comments
 (0)