Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 37 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ jobs:
- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Verify release workflow
run: node --test scripts/tests/release-workflow.test.cjs

- name: Run lint and tests with coverage
run: yarn lint && yarn test:coverage

Expand Down Expand Up @@ -82,6 +85,7 @@ jobs:

package_name="$(node -p "require('./package.json').name")"
base_branch="${GITHUB_REF_NAME}"
release_head="${GITHUB_SHA}"
published_version="$(npm view "$package_name" version 2>/dev/null || true)"
auth_header="$(
node -e "process.stdout.write('AUTHORIZATION: basic ' + Buffer.from('x-access-token:' + process.env.GITHUB_TOKEN).toString('base64'))"
Expand All @@ -91,6 +95,23 @@ jobs:
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin "$base_branch" --tags --force

# Older PRs were already shipped by the legacy release workflow.
release_baseline="$(git log "$release_head" --first-parent -n 1 --format=%H --extended-regexp --grep='^chore\(release\): .*Release v[^ ]+ for #[0-9]+$')"
if [ -z "$release_baseline" ]; then
echo "Cannot determine the historical release baseline."
exit 1
fi

is_queued_release() {
git merge-base --is-ancestor "$1" "$release_head" &&
! git merge-base --is-ancestor "$1" "$release_baseline"
}

create_release_tag() {
# Tag an existing remote commit; do not manufacture historical workflows.
git tag -a "$tag" "$merge_commit" -m "Release v${next_version} for #${pr_number}"
}

max_version() {
node - "$1" "$2" <<'NODE'
const [left, right] = process.argv.slice(2);
Expand Down Expand Up @@ -139,7 +160,10 @@ jobs:

while IFS= read -r tag; do
release_commit="$(git rev-parse "${tag}^{commit}")"
subject="$(git show -s --format=%s "$release_commit")"
subject="$(git for-each-ref --format='%(contents:subject)' "refs/tags/${tag}")"
if ! [[ "$subject" =~ ^Release\ v([^[:space:]]+)\ for\ \#${pr_number}$ ]]; then
subject="$(git show -s --format=%s "$release_commit")"
fi

if [[ "$subject" =~ Release\ v([^[:space:]]+)\ for\ \#${pr_number}$ ]]; then
version="${BASH_REMATCH[1]}"
Expand Down Expand Up @@ -232,6 +256,14 @@ jobs:
;;
esac

if [ -z "$merge_commit" ]; then
echo "Merged PR #${pr_number} has no merge commit."
exit 1
fi
if ! is_queued_release "$merge_commit"; then
continue
fi

if release_info="$(release_for_pr "$pr_number")"; then
IFS=$'\t' read -r tag version release_commit <<< "$release_info"

Expand All @@ -240,21 +272,18 @@ jobs:
else
echo "Resuming npm publication for PR #${pr_number} as ${version}."
git checkout --detach "$release_commit"
npm version "$version" --no-git-tag-version --allow-same-version
yarn install --frozen-lockfile
yarn build
npm publish --access public --tag latest
git restore -- package.json
fi

published_version="$(max_version "$published_version" "$version")"
ensure_github_release "$pr_json" "$tag" "$version" "$bump"
continue
fi

if [ -z "$merge_commit" ]; then
echo "Merged PR #${pr_number} has no merge commit."
exit 1
fi

git checkout --detach "$merge_commit"
manifest_version="$(node -p "require('./package.json').version")"
base_version="$(max_version "$manifest_version" "$published_version")"
Expand All @@ -270,14 +299,11 @@ jobs:
yarn install --frozen-lockfile
yarn build

git add package.json
git commit -m "chore(release): 🔖 Release v${next_version} for #${pr_number}"
release_commit="$(git rev-parse HEAD)"

git tag -a "$tag" "$release_commit" -m "Release v${next_version} for #${pr_number}"
create_release_tag
git -c "http.https://github.com/.extraheader=$auth_header" push origin "refs/tags/${tag}"

npm publish --access public --tag latest
git restore -- package.json
published_version="$next_version"
ensure_github_release "$pr_json" "$tag" "$next_version" "$bump"
done
Expand Down
106 changes: 106 additions & 0 deletions scripts/tests/release-workflow.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
const assert = require('node:assert/strict');
const { execFileSync, spawnSync } = require('node:child_process');
const { mkdtempSync, readFileSync, rmSync, writeFileSync } = require('node:fs');
const { tmpdir } = require('node:os');
const { resolve, join } = require('node:path');
const { test } = require('node:test');
const workflow = readFileSync(
resolve(__dirname, '../../.github/workflows/ci.yml'),
'utf8',
)
.split('\n')
.map((line) => (line.startsWith(' ') ? line.slice(10) : line))
.join('\n');
function shellFunction(name) {
const start = workflow.indexOf(`${name}() {`);
assert.notEqual(start, -1, `Missing workflow function ${name}`);
const end = workflow.indexOf('\n}', start);
assert.notEqual(end, -1);
return workflow.slice(start, end + 2);
}
function fixture(t) {
const cwd = mkdtempSync(join(tmpdir(), 'release-queue-test-'));
t.after(() => rmSync(cwd, { recursive: true, force: true }));
const git = (...args) =>
execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
git('init', '-b', 'master');
git('config', 'user.name', 'Test');
git('config', 'user.email', 'test@example.invalid');
const commit = (subject) => {
writeFileSync(join(cwd, 'file'), subject);
git('add', '.');
git('commit', '-m', subject);
return git('rev-parse', 'HEAD');
};
const historical = commit('old feature (#1)');
const baseline = commit('chore(release): 🔖 Release v4.0.1 for #51');
const pending = commit('new feature (#53)');
const head = commit('package feature (#59)');
const future = commit('later feature (#60)');
return { cwd, git, commit, historical, baseline, pending, head, future };
}
test('release queue excludes historical and future merges', (t) => {
const f = fixture(t);
for (const [commit, expected] of [
[f.historical, 1],
[f.baseline, 1],
[f.pending, 0],
[f.head, 0],
[f.future, 1],
]) {
const result = spawnSync(
'bash',
[
'-c',
`${shellFunction('is_queued_release')}\nrelease_baseline=$1; release_head=$2; is_queued_release "$3"`,
'test',
f.baseline,
f.head,
commit,
],
{ cwd: f.cwd, encoding: 'utf8' },
);
assert.equal(result.status, expected, result.stderr);
}
});
test('annotated release tag records version without creating a new commit', (t) => {
const f = fixture(t);
execFileSync(
'bash',
[
'-c',
`${shellFunction('create_release_tag')}\ntag=v5.0.0; next_version=5.0.0; pr_number=53; merge_commit=$1; create_release_tag`,
'test',
f.pending,
],
{ cwd: f.cwd },
);
assert.equal(f.git('rev-parse', 'v5.0.0^{commit}'), f.pending);
const result = execFileSync(
'bash',
['-c', `${shellFunction('release_for_pr')}\nrelease_for_pr 53`],
{ cwd: f.cwd, encoding: 'utf8' },
).trim();
assert.equal(result, `v5.0.0\t5.0.0\t${f.pending}`);
});
test('legacy release commits remain recognized and PR numbers match exactly', (t) => {
const f = fixture(t);
const release = f.commit('chore(release): 🔖 Release v4.0.1 for #51');
f.git('tag', '-a', 'v4.0.1', release, '-m', 'v4.0.1');
const result = execFileSync(
'bash',
['-c', `${shellFunction('release_for_pr')}\nrelease_for_pr 51`],
{ cwd: f.cwd, encoding: 'utf8' },
).trim();
assert.equal(result, `v4.0.1\t4.0.1\t${release}`);
const absent = spawnSync(
'bash',
['-c', `${shellFunction('release_for_pr')}\nrelease_for_pr 5`],
{ cwd: f.cwd },
);
assert.equal(absent.status, 1);
});
Loading