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
204 changes: 204 additions & 0 deletions .github/scripts/release-automation.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
const { Buffer } = require('node:buffer');

const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[\dA-Z][\d.A-Z-]*)?$/i;
const SHA_PATTERN = /^[0-9a-f]{40}$/;
const PENDING_LABEL = 'autorelease: pending';
const TAGGED_LABEL = 'autorelease: tagged';

function labelNames(pull) {
return pull.labels.flatMap((label) => (label.name ? [label.name] : []));
}

function requireOne(items, description) {
if (items.length !== 1) {
throw new Error(`Expected one ${description}, found ${items.length}`);
}
return items[0];
}

async function fileAtRef({ github, owner, path, ref, repo }) {
const { data } = await github.rest.repos.getContent({ owner, path, ref, repo });
if (Array.isArray(data) || data.type !== 'file') {
throw new Error(`Expected ${path} at ${ref} to be a file`);
}
return data;
}

async function releasesForTag({ github, owner, repo, tag }) {
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
return releases.filter((release) => release.tag_name === tag);
}

async function tagSha({ github, owner, repo, tag }) {
try {
const { data } = await github.rest.git.getRef({ owner, repo, ref: `tags/${tag}` });
return data.object.sha;
} catch (error) {
if (error.status === 404) return;
throw error;
}
}

async function resolveCandidate({ core, github, owner, repo, testedSha }) {
const { data: associatedPulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner,
repo,
commit_sha: testedSha,
});
const candidates = associatedPulls.filter(
(pull) =>
pull.base.ref === 'master' &&
pull.merge_commit_sha === testedSha &&
pull.head.ref.startsWith('release-please--'),
);

if (candidates.length === 0) {
core.setOutput('state', 'none');
return;
}
const candidate = requireOne(candidates, `release-please PR for ${testedSha}`);
const labels = labelNames(candidate);
const pending = labels.includes(PENDING_LABEL);
const tagged = labels.includes(TAGGED_LABEL);

const packageFile = await fileAtRef({
github,
owner,
repo,
path: 'apps/desktop/package.json',
ref: testedSha,
});
const version = JSON.parse(Buffer.from(packageFile.content, 'base64').toString()).version;
if (typeof version !== 'string' || !SEMVER_PATTERN.test(version)) {
throw new Error(`Invalid Desktop release version: ${version}`);
}

const tag = `v${version}`;
const releases = await releasesForTag({ github, owner, repo, tag });
if (releases.length > 1) throw new Error(`Found multiple matching releases for ${tag}`);

const existingSha = await tagSha({ github, owner, repo, tag });
if (existingSha && existingSha !== testedSha) {
throw new Error(`${tag} already points to ${existingSha}, expected ${testedSha}`);
}

core.setOutput('sha', testedSha);
core.setOutput('tag', tag);
if (existingSha && releases.length === 1) {
const release = releases[0];
if (release.target_commitish !== testedSha) {
throw new Error(`${tag} Release targets ${release.target_commitish}, expected ${testedSha}`);
}
if (!tagged) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: candidate.number,
labels: [TAGGED_LABEL],
});
}
if (pending) {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: candidate.number,
name: PENDING_LABEL,
});
}
core.setOutput('state', 'done');
return;
}

if (!pending) throw new Error(`Release PR for ${testedSha} is not pending`);
const closedPulls = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'closed',
base: 'master',
sort: 'updated',
direction: 'desc',
per_page: 100,
});
const pendingPulls = closedPulls.filter(
(pull) =>
pull.merged_at &&
pull.head.ref.startsWith('release-please--') &&
labelNames(pull).includes(PENDING_LABEL),
);
const pendingPull = requireOne(pendingPulls, 'merged pending release-please PR');
if (pendingPull.merge_commit_sha !== testedSha) {
throw new Error(`Pending release PR does not match CI-tested ${testedSha}`);
}
core.setOutput('state', 'pending');
}

async function verifyConfig({ github, owner, repo, testedSha }) {
const comparisons = await Promise.all(
['release-please-config.json', '.release-please-manifest.json'].map(async (path) => {
const [tested, current] = await Promise.all([
fileAtRef({ github, owner, repo, path, ref: testedSha }),
fileAtRef({ github, owner, repo, path, ref: 'master' }),
]);
return { current, path, tested };
}),
);
for (const { current, path, tested } of comparisons) {
if (current.sha !== tested.sha) {
throw new Error(`${path} changed after CI-tested release candidate ${testedSha}`);
}
}
}

async function verifyStartedRelease({
core,
github,
owner,
releaseCreated,
releasePleaseSha,
releasePleaseTag,
releaseSha,
releaseTag,
repo,
}) {
if (!SHA_PATTERN.test(releaseSha)) throw new Error(`Invalid release SHA: ${releaseSha}`);
if (releaseCreated !== 'true') {
throw new Error('release-please did not create the expected Desktop draft');
}
if (releasePleaseSha !== releaseSha || releasePleaseTag !== releaseTag) {
throw new Error('release-please output does not match the CI-tested release candidate');
}

const releases = await releasesForTag({ github, owner, repo, tag: releaseTag });
const drafts = releases.filter(
(release) => release.draft && release.target_commitish === releaseSha,
);
requireOne(drafts, `draft Release for ${releaseTag}`);
const existingSha = await tagSha({ github, owner, repo, tag: releaseTag });
if (existingSha !== releaseSha) {
throw new Error(`${releaseTag} points to ${existingSha}, expected ${releaseSha}`);
}
await core.summary
.addRaw(`Started signed Desktop release for ${releaseTag} at ${releaseSha}`)
.write();
}

async function verifyDesktopRelease({ github, owner, repo, sha, tag }) {
const release = requireOne(
await releasesForTag({ github, owner, repo, tag }),
`release-please Release for ${tag}`,
);
if (release.target_commitish !== sha) {
throw new Error(`${tag} Release targets ${release.target_commitish}, expected ${sha}`);
}
}

module.exports = {
resolveCandidate,
verifyConfig,
verifyDesktopRelease,
verifyStartedRelease,
};
125 changes: 125 additions & 0 deletions .github/scripts/release-automation.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Buffer } from 'node:buffer';
import { describe, expect, it, vi } from 'vitest';
import automation from './release-automation.cjs';

const SHA = 'a'.repeat(40);

function harness({ labels = ['autorelease: pending'], releases = [], tag } = {}) {
const pull = {
number: 42,
base: { ref: 'master' },
head: { ref: 'release-please--branches--master--components--desktop' },
labels: labels.map((name) => ({ name })),
merge_commit_sha: SHA,
merged_at: '2026-07-23T00:00:00Z',
};
const outputs = {};
const github = {
paginate: vi.fn((method) => (method === github.rest.repos.listReleases ? releases : [pull])),
rest: {
git: {
getRef: vi.fn(() => {
if (!tag) throw Object.assign(new Error('missing'), { status: 404 });
return { data: { object: { sha: tag } } };
}),
},
issues: { addLabels: vi.fn(), removeLabel: vi.fn() },
pulls: { list: vi.fn() },
repos: {
getContent: vi.fn(({ path }) => ({
data: {
type: 'file',
sha: 'blob',
content: Buffer.from(
path === 'apps/desktop/package.json' ? '{"version":"0.6.4"}' : '{}',
).toString('base64'),
},
})),
listPullRequestsAssociatedWithCommit: vi.fn(() => ({ data: [pull] })),
listReleases: vi.fn(),
},
},
};
return {
core: {
setOutput(name, value) {
outputs[name] = value;
},
},
github,
outputs,
};
}

describe('release automation', () => {
it('resolves the exact pending release PR', async () => {
const { core, github, outputs } = harness();
await automation.resolveCandidate({
core,
github,
owner: 'arcboxlabs',
repo: 'linkcode',
testedSha: SHA,
});
expect(outputs).toEqual({ sha: SHA, tag: 'v0.6.4', state: 'pending' });
});

it('recovers lifecycle labels after the tag and Release exist', async () => {
const releases = [{ tag_name: 'v0.6.4', target_commitish: SHA }];
const { core, github, outputs } = harness({ labels: [], releases, tag: SHA });
await automation.resolveCandidate({
core,
github,
owner: 'arcboxlabs',
repo: 'linkcode',
testedSha: SHA,
});
expect(github.rest.issues.addLabels).toHaveBeenCalledWith(
expect.objectContaining({ labels: ['autorelease: tagged'] }),
);
expect(outputs.state).toBe('done');
});

it('rejects release policy drift after the tested merge', async () => {
const { github } = harness();
github.rest.repos.getContent
.mockResolvedValueOnce({ data: { type: 'file', sha: 'tested' } })
.mockResolvedValueOnce({ data: { type: 'file', sha: 'current' } });
await expect(
automation.verifyConfig({ github, owner: 'arcboxlabs', repo: 'linkcode', testedSha: SHA }),
).rejects.toThrow('changed after CI-tested release candidate');
});

it('requires the Desktop Release to target the tag SHA', async () => {
const releases = [{ tag_name: 'v0.6.4', target_commitish: 'b'.repeat(40) }];
const { github } = harness({ releases });
await expect(
automation.verifyDesktopRelease({
github,
owner: 'arcboxlabs',
repo: 'linkcode',
sha: SHA,
tag: 'v0.6.4',
}),
).rejects.toThrow(`expected ${SHA}`);
});

it('verifies the draft and lightweight tag created by release-please', async () => {
const releases = [{ draft: true, tag_name: 'v0.6.4', target_commitish: SHA }];
const { github } = harness({ releases, tag: SHA });
const write = vi.fn();
const core = { summary: { addRaw: vi.fn(() => ({ write })) } };
await automation.verifyStartedRelease({
core,
github,
owner: 'arcboxlabs',
releaseCreated: 'true',
releasePleaseSha: SHA,
releasePleaseTag: 'v0.6.4',
releaseSha: SHA,
releaseTag: 'v0.6.4',
repo: 'linkcode',
});
expect(write).toHaveBeenCalledOnce();
});
});
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ on:

# Runners resolve through repo/org `vars` (ArcBox pins paid Blacksmith labels there);
# forks without the vars fall back to GitHub-hosted labels with zero setup.
# Pull request and release-branch CI keeps latest-run cancellation. Every master SHA gets a unique
# group so a release-PR merge cannot be canceled or replaced before its exact SHA is tested.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.ref == 'refs/heads/master' && github.sha || 'latest' }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}

permissions:
contents: read
Expand Down
Loading