Skip to content
Open
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
119 changes: 119 additions & 0 deletions .github/scripts/create-release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// biome-ignore-all lint/suspicious/noUndeclaredEnvVars: This script runs directly in GitHub Actions outside Turborepo.
import { fileURLToPath, pathToFileURL } from 'node:url';

import { readRelease } from './release-notes.mjs';

export async function publishRelease({
release,
packages,
repository,
sha,
token,
publishedPackages = [],
request = fetch,
}) {
if (!repository || !sha || !token) {
throw new Error('GITHUB_REPOSITORY, GITHUB_SHA and GH_TOKEN are required');
}

for (const published of publishedPackages) {
if (
!packages.some(
(pkg) =>
pkg.name === published.name && pkg.version === published.version
)
) {
throw new Error(
`Published package is outside the fixed release group: ${published.name}@${published.version}`
);
}
}

async function api(endpoint, body) {
const method = body ? 'POST' : 'GET';
const response = await request(
`https://api.github.com/repos/${repository}/${endpoint}`,
{
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
...(body ? { 'Content-Type': 'application/json' } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(30_000),
}
);
if (response.status === 404 && method === 'GET') return null;
if (!response.ok) {
throw new Error(
`GitHub ${method} ${endpoint} failed (${response.status})`
);
}
return response.json();
}

// The commits endpoint resolves both lightweight and annotated package tags.
const mainCommit = await api(
`commits/${encodeURIComponent(release.tag_name)}`
);
if (mainCommit?.sha !== sha) {
if (publishedPackages.length) {
throw new Error(
'The main package tag does not point to this release commit. Verify package publication and release tags before retrying.'
);
}
return 'No release tags for this commit; skipping';
}

const existing = await api(
`releases/tags/${encodeURIComponent(release.tag_name)}`
);
if (existing) return `Release already exists: ${existing.html_url}`;

// On retries published-packages can be empty or partial. Check the whole
// fixed group so the release never advertises an incomplete publication.
for (const pkg of packages) {
const tag = `${pkg.name}@${pkg.version}`;
if (tag === release.tag_name) continue;
const commit = await api(`commits/${encodeURIComponent(tag)}`);
if (commit?.sha !== sha) {
throw new Error(
`Package tag is missing or points to another commit: ${tag}. Verify package publication and release tags before retrying.`
);
}
}

const created = await api('releases', release);
return `Created release: ${created.html_url}`;
}

async function main() {
const data = await readRelease(
fileURLToPath(new URL('../../', import.meta.url))
);
if (process.argv.includes('--dry-run')) {
console.log(JSON.stringify(data.release, null, 2));
return;
}
console.log(
await publishRelease({
...data,
repository: process.env.GITHUB_REPOSITORY,
sha: process.env.GITHUB_SHA,
token: process.env.GH_TOKEN,
publishedPackages: JSON.parse(process.env.PUBLISHED_PACKAGES || '[]'),
})
);
}

if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
166 changes: 166 additions & 0 deletions .github/scripts/create-release.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';

import { publishRelease } from './create-release.mjs';
import { generateReleaseNotes } from './release-notes.mjs';

const packages = JSON.parse(
await readFile(
new URL('./fixtures/release-5.4.2.json', import.meta.url),
'utf8'
)
);
const release = generateReleaseNotes(packages);
const sha = 'a'.repeat(40);

function mockGitHub({
missingTag,
wrongTag,
existing = false,
failCreateOnce = false,
denied = false,
} = {}) {
const calls = [];
let exists = existing;
let failed = false;
return {
calls,
request: async (url, options) => {
const endpoint = decodeURIComponent(
url.split('/repos/callstack/repack/')[1]
);
calls.push({ endpoint, ...options });
let status = 200;
let body;
if (denied) {
status = 403;
} else if (endpoint.startsWith('commits/')) {
const tag = endpoint.slice('commits/'.length);
status = tag === missingTag ? 404 : 200;
body = { sha: tag === wrongTag ? 'b'.repeat(40) : sha };
} else if (endpoint.startsWith('releases/tags/')) {
status = exists ? 200 : 404;
body = {
html_url: 'https://github.com/callstack/repack/releases/tag/existing',
};
} else if (endpoint === 'releases' && options.method === 'POST') {
if (failCreateOnce && !failed) {
status = 500;
failed = true;
} else {
status = 201;
exists = true;
body = {
html_url:
'https://github.com/callstack/repack/releases/tag/created',
};
}
} else {
assert.fail(`Unexpected GitHub request: ${options.method} ${endpoint}`);
}
return new Response(JSON.stringify(body ?? {}), { status });
},
};
}

function publish(github, overrides = {}) {
return publishRelease({
release,
packages,
repository: 'callstack/repack',
sha,
token: 'test-token',
request: github.request,
...overrides,
});
}

test('six published packages produce exactly one release on the existing main tag', async () => {
const github = mockGitHub();
await publish(github, { publishedPackages: packages });
const posts = github.calls.filter((call) => call.method === 'POST');
assert.equal(posts.length, 1);
assert.equal(
github.calls.filter((call) => call.endpoint.startsWith('commits/')).length,
6
);
assert.deepEqual(JSON.parse(posts[0].body), release);
assert.equal(posts[0].headers.Authorization, 'Bearer test-token');
});

test('an existing release is preserved without an update or duplicate', async () => {
const github = mockGitHub({ existing: true });
assert.match(await publish(github), /already exists/);
assert.equal(github.calls.filter((call) => call.method !== 'GET').length, 0);
});

test('retry after GitHub failure works without new npm publications and keeps all notes', async () => {
const github = mockGitHub({ failCreateOnce: true });
await assert.rejects(
publish(github, { publishedPackages: packages }),
/failed \(500\)/
);
assert.match(
await publish(github, { publishedPackages: [] }),
/Created release/
);
assert.match(await publish(github), /already exists/);
const posts = github.calls.filter((call) => call.method === 'POST');
assert.equal(posts.length, 2);
assert.deepEqual(JSON.parse(posts[1].body), release);
});

test('a partial publish report still uses notes from the complete fixed group', async () => {
const github = mockGitHub();
await publish(github, { publishedPackages: [packages[1]] });
const posted = JSON.parse(
github.calls.find((call) => call.method === 'POST').body
);
assert.match(posted.body, /Bootstrap new projects/);
assert.match(posted.body, /Fix `\.jpeg` images/);
});

test('a normal push without release tags does not create a release', async () => {
const github = mockGitHub({ wrongTag: release.tag_name });
assert.match(await publish(github), /skipping/);
assert.equal(github.calls.length, 1);
});

test('a reported publication without its main tag fails visibly', async () => {
const github = mockGitHub({ missingTag: release.tag_name });
await assert.rejects(
publish(github, { publishedPackages: packages }),
/main package tag/
);
});

for (const option of ['missingTag', 'wrongTag']) {
test(`${option} prevents advertising an incomplete or mixed-commit release`, async () => {
const github = mockGitHub({ [option]: `${packages[1].name}@5.4.2` });
await assert.rejects(
publish(github),
/Package tag is missing or points to another commit/
);
assert.equal(
github.calls.filter((call) => call.method === 'POST').length,
0
);
});
}

test('permission errors are not treated as a missing release', async () => {
const github = mockGitHub({ denied: true });
await assert.rejects(publish(github), /failed \(403\)/);
});

test('independently versioned packages require an explicit release policy', async () => {
const github = mockGitHub();
await assert.rejects(
publish(github, {
publishedPackages: [{ name: '@callstack/repack-expo', version: '0.0.2' }],
}),
/outside the fixed release group/
);
assert.equal(github.calls.length, 0);
});
32 changes: 32 additions & 0 deletions .github/scripts/fixtures/release-5.4.2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[
{
"name": "@callstack/repack",
"version": "5.4.2",
"changelog": "# @callstack/repack\n\n## 5.4.2\n\n### Patch Changes\n\n- [#1480](https://github.com/callstack/repack/pull/1480) [`166388b`](https://github.com/callstack/repack/commit/166388b76fc4641f15cd63d3e96aec9d7e582c84) Thanks [@CristiCeban](https://github.com/CristiCeban)! - Fix `.jpeg` images not showing in Android release builds. They were emitted to `raw` instead of `drawable-*`, where React Native looks for them.\n\n If you have a `.jpeg` and a `.png`/`.jpg`/`.gif`/`.webp` image with the same name in the same directory (e.g. `logo.png` and `logo.jpeg`), rename one of them. Both now resolve to the same Android drawable resource name, and the build fails with a duplicate resources error.\n\n- Updated dependencies []:\n - @callstack/repack-dev-server@5.4.2\n\n## 5.4.1\n\n### Patch Changes\n\n- [#1479](https://github.com/callstack/repack/pull/1479) [`811a6d8`](https://github.com/callstack/repack/commit/811a6d84ed09ad83f4395c85218e055e7e9abf91) Thanks [@CristiCeban](https://github.com/CristiCeban)! - Ship consumer ProGuard rules for the Android library so release builds with R8 (`minifyEnabled true`) no longer fail with missing `com.google.errorprone.annotations` classes referenced by `nimbus-jose-jwt`.\n\n- [#1476](https://github.com/callstack/repack/pull/1476) [`5bb41f0`](https://github.com/callstack/repack/commit/5bb41f045f43d935fc79b153f889a1195a0f19ad) Thanks [@whydidoo](https://github.com/whydidoo)! - Support React Native 0.88's Flow parser in Babel loaders, native component codegen, and Reanimated transforms while retaining Hermes parser compatibility with older React Native presets.\n\n- [#1473](https://github.com/callstack/repack/pull/1473) [`f9326a0`](https://github.com/callstack/repack/commit/f9326a0a5fcc7de753190fc7576b91320498dfa8) Thanks [@whydidoo](https://github.com/whydidoo)! - Emit an additional `res/raw` copy of bundled Android font binaries so Metro-compatible consumers such as Skia can load them by name. Preserve the existing `res/font` output for native `@font/...`, `R.font`, and XML font-family references. Generate `res/raw/keep.xml` for bundled Android resources so resource shrinking preserves both font copies and other assets loaded by name at runtime.\n\n XML font-family resources continue to use `res/font`. Asset metadata and iOS, development-server, inline, and remote-loader output paths are unchanged.\n\n- Updated dependencies []:\n - @callstack/repack-dev-server@5.4.1\n\n"
},
{
"name": "@callstack/repack-dev-server",
"version": "5.4.2",
"changelog": "# @callstack/repack-dev-server\n\n## 5.4.2\n\n## 5.4.1\n\n"
},
{
"name": "@callstack/repack-init",
"version": "5.4.2",
"changelog": "# @callstack/repack-init\n\n## 5.4.2\n\n### Patch Changes\n\n- [#1482](https://github.com/callstack/repack/pull/1482) [`4139cf3`](https://github.com/callstack/repack/commit/4139cf3b9fa5f0f80260bfd0b783621543bf3949) Thanks [@jacekAtCK](https://github.com/jacekAtCK)! - Bootstrap new projects with React Native 0.87.1 and refresh the default bundler dependencies to `@rspack/core@^1.7.12`, `@swc/helpers@^0.5.23`, `webpack@^5.111.1` and `terser-webpack-plugin@^5.6.1`. Generated Rspack and webpack configs now enable package exports (`getResolveOptions({ enablePackageExports: true })`).\n\n## 5.4.1\n\n"
},
{
"name": "@callstack/repack-plugin-reanimated",
"version": "5.4.2",
"changelog": "# @callstack/repack-plugin-reanimated\n\n## 5.4.2\n\n## 5.4.1\n\n### Patch Changes\n\n- [#1476](https://github.com/callstack/repack/pull/1476) [`5bb41f0`](https://github.com/callstack/repack/commit/5bb41f045f43d935fc79b153f889a1195a0f19ad) Thanks [@whydidoo](https://github.com/whydidoo)! - Support React Native 0.88's Flow parser in Babel loaders, native component codegen, and Reanimated transforms while retaining Hermes parser compatibility with older React Native presets.\n\n"
},
{
"name": "@callstack/repack-plugin-nativewind",
"version": "5.4.2",
"changelog": "# @callstack/repack-plugin-nativewind\n\n## 5.4.2\n\n## 5.4.1\n\n"
},
{
"name": "@callstack/repack-plugin-expo-modules",
"version": "5.4.2",
"changelog": "# @callstack/repack-plugin-expo-modules\n\n## 5.4.2\n\n## 5.4.1\n\n"
}
]
Loading