ci: Initialize ci helpers and support scripts for ci automation via mjs (#25931)

This commit is contained in:
Matsu 2026-02-19 11:56:48 +02:00 committed by GitHub
parent bd92dabc5c
commit 581f74014b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 512 additions and 1 deletions

View File

@ -18,6 +18,10 @@ inputs:
description: 'Command to execute for building the project or an optional command. Leave empty to skip build step.' description: 'Command to execute for building the project or an optional command. Leave empty to skip build step.'
required: false required: false
default: 'pnpm build' default: 'pnpm build'
install-command:
description: 'Command to execute for installing project dependencies. Leave empty to skip install step.'
required: false
default: 'pnpm install --frozen-lockfile'
runs: runs:
using: 'composite' using: 'composite'
@ -44,7 +48,9 @@ runs:
shell: bash shell: bash
- name: Install Dependencies - name: Install Dependencies
run: pnpm install --frozen-lockfile if: ${{ inputs.install-command != '' }}
run: |
${{ inputs.install-command }}
shell: bash shell: bash
- name: Disable safe-chain - name: Disable safe-chain

View File

@ -0,0 +1,73 @@
import semver from 'semver';
import {
ensureEnvVar,
RELEASE_PREFIX,
sh,
stripReleasePrefixes,
trySh,
writeGithubOutput,
} from './github-helpers.mjs';
function remoteBranchExists(branch) {
const res = trySh('git', ['ls-remote', '--heads', 'origin', branch]);
return res.ok && res.out.length > 0;
}
function localRefExists(ref) {
const res = trySh('git', ['show-ref', '--verify', '--quiet', ref]);
return res.ok;
}
function main() {
const rawVersion = ensureEnvVar('VERSION');
const version = stripReleasePrefixes(rawVersion);
if (!semver.valid(version)) {
throw new Error(
`Invalid VERSION. Expected semver like X.Y.Z (optionally prefixed by n8n@). Got: ${rawVersion}`,
);
}
const major = semver.major(version);
const minor = semver.minor(version);
const branch = `release-candidate/${major}.${minor}.x`;
// We create the RC branch from the corresponding release tag
const releaseTag = `${RELEASE_PREFIX}${version}`;
if (remoteBranchExists(branch)) {
console.log(`Branch already exists on origin: ${branch}`);
writeGithubOutput({ branch });
return;
}
// Ensure the tag exists locally (as a tag ref)
// `git rev-parse <tag>^{}` will fail if it doesn't exist.
const tagCommitRes = trySh('git', ['rev-parse', `${releaseTag}^{}`]);
if (!tagCommitRes.ok || !tagCommitRes.out) {
throw new Error(
`Cannot find release tag "${releaseTag}". Make sure the tag exists and has been pushed.`,
);
}
console.log(`Creating branch ${branch} from ${releaseTag} (${tagCommitRes.out})`);
// Create local branch (force safe: it shouldn't exist, but keep it robust)
if (localRefExists(`refs/heads/${branch}`)) {
sh('git', ['branch', '-f', branch, tagCommitRes.out]);
} else {
sh('git', ['switch', '-c', branch, tagCommitRes.out]);
}
sh('git', ['push', 'origin', branch]);
console.log(`Created and pushed: ${branch}`);
writeGithubOutput({ branch });
}
try {
main();
} catch (err) {
console.error(String(err?.message ?? err));
process.exit(1);
}

View File

@ -0,0 +1,63 @@
import semver from 'semver';
import {
getCommitForRef,
listTagsPointingAt,
RELEASE_PREFIX,
RELEASE_TRACKS,
stripReleasePrefixes,
writeGithubOutput,
} from './github-helpers.mjs';
/**
* Given a list of tag names, return the highest semver tag (keeping the original 'v' prefix),
* or "" if none match semver.
*
* @param {string[]} tags
**/
function highestSemverTag(tags) {
const candidates = tags
.filter((t) => t.startsWith(RELEASE_PREFIX))
.map((t) => ({
tag: t,
version: stripReleasePrefixes(t),
}))
.filter(({ version }) => semver.valid(version));
if (candidates.length === 0) return '';
candidates.sort((a, b) => semver.rcompare(a.version, b.version));
return candidates[0]?.tag;
}
/**
* @param {string} track
**/
function getSemverTagForTrack(track) {
const commit = getCommitForRef(track);
if (!commit) return '';
const tags = listTagsPointingAt(commit);
return highestSemverTag(tags);
}
function main() {
/** @type { Record<string, string> } */
const outputs = {};
for (const track of RELEASE_TRACKS) {
outputs[track] = getSemverTagForTrack(track);
}
writeGithubOutput(outputs);
console.log('Current release versions: ');
for (const [k, v] of Object.entries(outputs)) {
console.log(`${k}: ${v || '(not found)'}`);
}
}
try {
main();
} catch (err) {
console.error(String(err?.message ?? err));
process.exit(1);
}

183
.github/scripts/github-helpers.mjs vendored Normal file
View File

@ -0,0 +1,183 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import semver from 'semver';
export const RELEASE_TRACKS = /** @type { const } */ ([
//
'stable',
'beta',
'v1',
]);
export const RELEASE_PREFIX = 'n8n@';
/**
* Given a list of tags, return the highest semver for tags like "n8n@2.7.0".
* Returns the *tag string* (e.g. "n8n@2.7.0") or null.
*
* @param {string[]} tags
* */
export function pickHighestReleaseTag(tags) {
const versions = tags
.filter((t) => t.startsWith(RELEASE_PREFIX))
.map((t) => ({ tag: t, v: stripReleasePrefixes(t) }))
.filter(({ v }) => semver.valid(v))
.sort((a, b) => semver.rcompare(a.v, b.v));
return versions[0]?.tag ?? null;
}
/**
* @param {string} track
*
* @returns { typeof RELEASE_TRACKS[number] }
* */
export function ensureReleaseTrack(track) {
if (!RELEASE_TRACKS.includes(track)) {
throw new Error(`Invalid track ${track}. Available tracks are ${RELEASE_TRACKS.join(', ')}`);
}
return track;
}
/**
* Resolve a release track tag (stable/beta/etc.) to the corresponding
* release-candidate/<major>.<minor>.x branch, based on the n8n@<x.y.z> tag
* pointing at the same commit.
*
* Returns null if the track tag or release tag is missing.
*
* @param { typeof RELEASE_TRACKS[number] } track
* */
export function resolveRcBranchForTrack(track) {
const commit = getCommitForRef(track);
if (!commit) return null;
const tagsAtCommit = listTagsPointingAt(commit);
const releaseTag = pickHighestReleaseTag(tagsAtCommit);
if (!releaseTag) return null;
const version = stripReleasePrefixes(releaseTag);
const parsed = semver.parse(version);
if (!parsed) return null;
return `release-candidate/${parsed.major}.${parsed.minor}.x`;
}
/**
* @param {string} tag
* */
export function stripReleasePrefixes(tag) {
return tag.startsWith(RELEASE_PREFIX) ? tag.slice(RELEASE_PREFIX.length) : tag;
}
/**
* @returns { string[] }
* */
export function readPrLabels() {
const eventPath = ensureEnvVar('GITHUB_EVENT_PATH');
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
/** @type { string[] | { name: string }[] } */
const labels = event?.pull_request?.labels ?? [];
return labels.map((l) => (typeof l === 'string' ? l : l?.name)).filter(Boolean);
}
/**
* Ensures git tag exists.
*
* @throws { Error } if no tag was found
* */
export function ensureTagExists(tag) {
sh('git', ['fetch', '--force', 'origin', `refs/tags/${tag}:refs/tags/${tag}`]);
}
/**
* @param {string} bump
*
* @returns { bump is import("semver").ReleaseType }
* */
export function isReleaseType(bump) {
return ['major', 'minor', 'patch'].includes(bump);
}
/**
* @param {string} variableName
*/
export function ensureEnvVar(variableName) {
const v = process.env[variableName];
if (!v) {
throw new Error(`Missing required env var: ${variableName}`);
}
return v;
}
/**
* @param {string} cmd
* @param {readonly string[]} args
* @param {import("node:child_process").ExecFileOptionsWithStringEncoding} args
*
* @example sh("git", ["tag", "--points-at", commit]);
* */
export function sh(cmd, args, opts = {}) {
return execFileSync(cmd, args, { encoding: 'utf8', ...opts }).trim();
}
/**
* @param {string} cmd
* @param {readonly string[]} args
* @param {import("node:child_process").ExecFileOptionsWithStringEncoding} args
*
* @example trySh("git", ["tag", "--points-at", commit]);
* */
export function trySh(cmd, args, opts = {}) {
try {
return { ok: true, out: sh(cmd, args, opts) };
} catch {
return { ok: false, out: '' };
}
}
/**
* Append outputs to GITHUB_OUTPUT if available.
*
* @param {Record<string, string>} obj
*/
export function writeGithubOutput(obj) {
const path = process.env.GITHUB_OUTPUT;
if (!path) return;
const lines = Object.entries(obj)
.map(([k, v]) => `${k}=${v ?? ''}`)
.join('\n');
fs.appendFileSync(path, lines + '\n', 'utf8');
}
/**
* Resolve a ref (tag/branch/SHA) to the underlying commit SHA.
* Uses ^{} so annotated tags are peeled to the commit.
* Returns null if ref doesn't exist.
*
* @param {string} ref
*/
export function getCommitForRef(ref) {
const res = trySh('git', ['rev-parse', `${ref}^{}`]);
return res.ok && res.out ? res.out : null;
}
/**
* List all tags that point at the given commit SHA.
*
* @param {string} commit
*/
export function listTagsPointingAt(commit) {
const res = trySh('git', ['tag', '--points-at', commit]);
if (!res.ok || !res.out) return [];
return res.out
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
}

18
.github/scripts/move-track-tag.mjs vendored Normal file
View File

@ -0,0 +1,18 @@
import { ensureEnvVar, ensureReleaseTrack, ensureTagExists, sh } from './github-helpers.mjs';
function main() {
const trackEnv = ensureEnvVar('TRACK');
const track = ensureReleaseTrack(trackEnv);
const versionInput = ensureEnvVar('VERSION_TAG'); // e.g. n8n@2.7.0
ensureTagExists(versionInput);
sh('git', ['tag', '-f', track, versionInput]);
sh('git', ['push', 'origin', '-f', `refs/tags/${track}:refs/tags/${track}`]);
console.log(`Moved pointer tag ${track} to point to ${versionInput}`);
}
main();

62
.github/scripts/plan-release.mjs vendored Normal file
View File

@ -0,0 +1,62 @@
import semver from 'semver';
import {
ensureEnvVar,
isReleaseType,
RELEASE_PREFIX,
stripReleasePrefixes,
writeGithubOutput,
} from './github-helpers.mjs';
const track = ensureEnvVar('TRACK');
const bump = ensureEnvVar('BUMP');
const stable = process.env['STABLE_VERSION'];
const beta = process.env['BETA_VERSION'];
const v1 = process.env['V1_VERSION'];
let base = null;
switch (track) {
case 'stable':
base = stable;
break;
case 'beta':
base = beta;
break;
case 'v1':
base = v1;
break;
}
if (!base) {
console.error(
`Unknown track or missing base version. track=${track} stable=${stable} beta=${beta} v1=${v1}`,
);
process.exit(1);
}
const cleanedBase = stripReleasePrefixes(base);
if (!cleanedBase) {
console.error(`Invalid base version: ${base}`);
process.exit(1);
}
if (!isReleaseType(bump)) {
console.error(`Invalid release type in $bump: ${bump}`);
process.exit(1);
}
const next = semver.inc(cleanedBase, bump);
if (!next) {
console.error(`Could not bump version. base=${cleanedBase} bump=${bump}`);
process.exit(1);
}
const output = {
base_version: cleanedBase,
new_version: next,
new_version_tag: `${RELEASE_PREFIX}${next}`,
};
writeGithubOutput(output);
console.log(`Releasing track=${track} bump=${bump} base=${cleanedBase} -> new=${next}`);

View File

@ -0,0 +1,52 @@
name: Ensure release-candidate branch
on:
workflow_dispatch:
inputs:
version:
description: 'Latest release version (e.g. 2.8.1 or n8n@2.8.1)'
required: true
type: string
workflow_call:
inputs:
version:
description: 'Latest release version (e.g. 2.8.1 or n8n@2.8.1)'
required: true
type: string
outputs:
branch:
description: 'release-candidate branch name (e.g. release-candidate/2.8.x)'
value: ${{ jobs.ensure.outputs.branch }}
jobs:
ensure:
runs-on: ubuntu-slim
permissions:
contents: write
outputs:
branch: ${{ steps.rc.outputs.branch }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
fetch-tags: true
- name: Setup NodeJS
uses: ./.github/actions/setup-nodejs
with:
install-command: npm install --prefix=.github/scripts --no-package-lock
build-command: ''
- name: Ensure release-candidate branch exists
id: rc
env:
VERSION: ${{ inputs.version }}
run: node ./.github/scripts/create-release-candidate-branch.mjs
- name: Print branch
run: |
echo "Release-candidate branch: ${{ steps.rc.outputs.branch }}"

View File

@ -0,0 +1,54 @@
name: 'Release: Update pointer tag'
on:
workflow_call:
inputs:
track:
required: true
type: string
version-tag:
required: true
type: string
workflow_dispatch:
inputs:
track:
description: 'Release Track'
required: true
type: choice
options: [stable, beta, v1]
version-tag:
description: 'Version tag (e.g. n8n@2.7.0). Track tag will point to this version tag.'
required: true
type: string
permissions:
contents: write
jobs:
update-pointer-tags:
name: Update pointer tags
runs-on: ubuntu-slim
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
fetch-tags: true
- name: Setup NodeJS
uses: ./.github/actions/setup-nodejs
with:
install-command: npm install --prefix=.github/scripts --no-package-lock
build-command: ''
- name: Configure git author
run: |
git config user.name "n8n-release-tag-merge[bot]"
git config user.email "256767729+n8n-release-tag-merge[bot]@users.noreply.github.com"
- name: Move track tag
env:
TRACK: ${{ inputs.track }}
VERSION_TAG: ${{ inputs.version-tag }}
run: node ./.github/scripts/move-track-tag.mjs