Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
23 changes: 23 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,29 @@ jobs:
npm run build
if: ${{ steps.release.outputs.releases_created == 'true' }}

#-----------------------------------------------------------------------------
# NOTE: This script currently doesn't do anything. It just outputs the
# release information to the console. We will do this for a few releases
# to make sure everything is working correctly before we switch to use this
# script exclusively.
#-----------------------------------------------------------------------------

- name: Publish using new script
run: node scripts/publish.js --dry-run
if: ${{ steps.release.outputs.releases_created == 'true' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
TWITTER_API_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }}
TWITTER_API_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }}
TWITTER_ACCESS_TOKEN_KEY: ${{ secrets.TWITTER_ACCESS_TOKEN_KEY }}
TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }}
MASTODON_HOST: ${{ secrets.MASTODON_HOST }}
BLUESKY_IDENTIFIER: ${{ vars.BLUESKY_IDENTIFIER }}
BLUESKY_PASSWORD: ${{ secrets.BLUESKY_PASSWORD }}
BLUESKY_HOST: ${{ vars.BLUESKY_HOST }}

#-----------------------------------------------------------------------------
# NOTE: Packages are released in order of dependency. The packages with the
# fewest internal dependencies are released first and the packages with the
Expand Down
92 changes: 5 additions & 87 deletions scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,98 +10,16 @@
//------------------------------------------------------------------------------

import { execSync } from "node:child_process";
import path from "node:path";
import fsp from "node:fs/promises";
import { fileURLToPath } from "node:url";

//-----------------------------------------------------------------------------
// Data
//-----------------------------------------------------------------------------

const __filename = fileURLToPath(import.meta.url); // eslint-disable-line no-underscore-dangle -- convention
const __dirname = path.dirname(__filename); // eslint-disable-line no-underscore-dangle -- convention
const PACKAGES_DIR = path.resolve(__dirname, "..", "packages");
import {
getPackageDirs,
calculatePackageDependencies,
createBuildOrder,
} from "./shared.js";

//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------

/**
* Gets a list of directories in the packages directory.
* @returns {Promise<string[]>} A promise that resolves with an array of package directories.
*/
async function getPackageDirs() {
const packageDirs = await fsp.readdir(PACKAGES_DIR);
return packageDirs.map(entry => `packages/${entry}`);
}

/**
* Calculates the dependencies between packages.
* @param {Array<string>} packageDirs An array of package directories.
* @returns {Map<string, Set<string>>} A map of package names to the set of dependencies.
*/
async function calculatePackageDependencies(packageDirs) {
return new Map(
await Promise.all(
packageDirs.map(async packageDir => {
const packageJson = await fsp.readFile(
path.join(packageDir, "package.json"),
"utf8",
);
const pkg = JSON.parse(packageJson);
const dependencies = new Set();

if (pkg.dependencies) {
for (const dep of Object.keys(pkg.dependencies)) {
dependencies.add(dep);
}
}

if (pkg.devDependencies) {
for (const dep of Object.keys(pkg.devDependencies)) {
dependencies.add(dep);
}
}

return [
pkg.name,
{ name: pkg.name, dir: packageDir, dependencies },
];
}),
),
);
}

/**
* Creates an array of directories to be built in order to satisfy dependencies.
* @param {Map<string,{name:string,dir:string,dependencies:Set<string>}>} dependencies The
* dependencies between packages.
* @returns {Array<string>} An array of directories to be built in order.
*/
function createBuildOrder(dependencies) {
const buildOrder = [];
const seen = new Set();

function visit(name) {
if (!seen.has(name)) {
seen.add(name);

// we only need to deal with dependencies in this monorepo
if (dependencies.has(name)) {
const { dependencies: deps, dir } = dependencies.get(name);
deps.forEach(visit);
buildOrder.push(dir);
}
}
}

dependencies.forEach((value, key) => {
visit(key);
});

return buildOrder;
}

/**
* Builds the packages in the correct order.
* @param {Array<string>} packageDirs An array of directories to build in order.
Expand Down
265 changes: 265 additions & 0 deletions scripts/publish.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
/**
* @fileoverview Publishes all of the packages in dependency order
* via GitHub workflow.
*
* Usage:
*
* node scripts/publish.js [--dry-run]
*
* @author Nicholas C. Zakas
*/

//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------

import {
getPackageDirs,
calculatePackageDependencies,
createBuildOrder,
} from "./shared.js";
import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";

//-----------------------------------------------------------------------------
// Read CLI Args
//-----------------------------------------------------------------------------

const dryRun = process.argv.includes("--dry-run");

// for dry runs only output to console and don't execute anything
const exec = dryRun ? text => console.log(text) : execSync;

//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------

/**
* Converts a GitHub Actions step output name to its corresponding environment variable name
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do GitHub Actions automatically set environment variables for step outputs? I couldn't find any docs about this. I tried the following and it didn't work:

name: Test Steps Output
on: workflow_dispatch
jobs:
    test:
        runs-on: ubuntu-latest
        steps:
            - id: release
              run: echo "packages/config-array--release_created=true" >> "$GITHUB_OUTPUT"

            # prints "true"  
            - run: echo result from steps ${{ steps.release.outputs['packages/config-array--release_created'] }}            

            # prints nothing
            - run: echo result from env $STEPS_RELEASE_OUTPUTS_PACKAGES__CONFIG_ARRAY__RELEASE_CREATED

            # no env variable there
            - run: export

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. I was trying to cobble this together from the (awful) GitHub workflows docs.

How are you testing?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm testing by running the above workflow. Here's a run example:

https://github.com/mdjermanovic/test-steps-env/actions/runs/13438455360/job/37546518902

${{ steps.release.outputs['packages/config-array--release_created'] }} works as expected, but it doesn't seem that $STEPS_RELEASE_OUTPUTS_PACKAGES__CONFIG_ARRAY__RELEASE_CREATED was set.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I thought you might be running locally. 😄

It looks like my AI companion got this one wrong, I'll dig in.

* @param {string} stepId The ID of the step
* @param {string} outputName The name of the output
* @returns {string} The environment variable name
*/
function convertOutputToEnvVar(stepId, outputName) {
// Convert step ID to uppercase and replace hyphens with underscores
const normalizedStepId = stepId.toUpperCase().replace(/-/gu, "_");

// Convert output name to uppercase and replace slashes with double underscores
const normalizedOutputName = outputName
.toUpperCase()
.replace(/\//gu, "__")
.replace(/-/gu, "_");

return `STEPS_${normalizedStepId}_OUTPUTS_${normalizedOutputName}`;
}

/**
* Gets the output of a GitHub Actions step from the environment variables.
* @param {string} packageDir The directory of the package.
* @param {string} name The name of the output.
* @return {string} The output value.
*/
function getReleaseOutput(packageDir, name) {
return process.env[
convertOutputToEnvVar("release", `${packageDir}--${name}`)
];
}

/**
* Gets the list of packages to publish.
* @param {Array<string>} packageDirs The list of package directories.
* @return {Array<string>} The list of packages to publish.
*/
function getPackagesToPublish(packageDirs) {
return packageDirs.filter(
packageDir =>
getReleaseOutput(packageDir, "release_created") === "true",
);
}

/**
* Maps the dependencies into a structure where they keys are package
* paths and the values are an array of package paths.
* @param {Map<string, { name:string, dir: string, dependencies: Set<string> }>} dependencies The dependencies to map.
* @return {Map<string, Set<string>>} The mapped dependencies.
*/
function mapDependenciesToPaths(dependencies) {
const mappedDependencies = new Map();

for (const [, { dir: packageDir, dependencies: deps }] of dependencies) {
const pathDeps = [...deps]
.filter(dep => dependencies.has(dep))
.map(dep => {
const depDir = dependencies.get(dep);
if (depDir) {
return depDir.dir;
}
return dep;
});
mappedDependencies.set(packageDir, new Set(pathDeps));
}

return mappedDependencies;
}

/**
* Publishes the packages to npm. If one package fails to publish, the rest
* will still be published.
* @param {Array<string>} packageDirs The list of package directories.
* @param {Map<string, Set<string>>} dependencies The dependencies between packages.
* @return {Map<string,string>} A map of package directory to whether it was published successfully.
*/
function publishPackagesToNpm(packageDirs, dependencies) {
console.log(
`Publishing packages to npm in this order: ${packageDirs.join(", ")}`,
);

const results = new Map();

for (const packageDir of packageDirs) {
// check if any dependencies previously failed
const deps = dependencies.get(packageDir);

if (deps && [...deps].some(dep => results.get(dep) !== "ok")) {
console.log(`Skipping ${packageDir} (missing dependencies)`);
results.set(packageDir, "Skipped (missing dependencies)");
continue;
}

console.log(`Publishing ${packageDir}...`);
try {
exec(`npm publish -w ${packageDir} --provenance`, {
stdio: "inherit",
env: process.env,
});

results.set(packageDir, "ok");
} catch (error) {
console.error(`Failed to publish ${packageDir} to npm`);
console.log(error.message);

results.set(packageDir, error.message);
}
}

console.log("Done publishing packages to npm.");
return results;
}

/**
* Publishes the packages to JSR. If one package fails to publish, the rest
* will still be published.
* @param {Array<string>} packageDirs The list of package directories.
* @return {Map<string,string>} A map of package directory to whether it was published successfully.
**/
function publishPackagesToJsr(packageDirs) {
console.log(
`Publishing packages to JSR in this order: ${packageDirs.join(", ")}`,
);

const results = new Map();

for (const packageDir of packageDirs) {
// Skip if no jsr.json exists
if (!existsSync(join(packageDir, "jsr.json"))) {
console.log(`Skipping ${packageDir} (no jsr.json found)`);
results.set(packageDir, "ok (skipped)");
continue;
}

console.log(`Publishing ${packageDir}...`);
try {
exec(`npx jsr publish`, {
stdio: "inherit",
env: process.env,
cwd: packageDir,
});

results.set(packageDir, "ok");
} catch (error) {
console.error(`Failed to publish ${packageDir} to JSR`);
console.log(error.message);

results.set(packageDir, error.message);
}
}

console.log("Done publishing packages to JSR.");
return results;
}

/**
* Posts the results to social media.
* @param {Map<string,string>} npmPublishResults The results of the npm publish.
* @return {void}
*/
function postResultToSocialMedia(npmPublishResults) {
const messages = [];

for (const [packageDir, result] of npmPublishResults) {
if (result !== "ok") {
continue;
}

const packageJson = JSON.parse(
readFileSync(join(packageDir, "package.json"), "utf8"),
);
const packageName = packageJson.name.slice(1); // remove leading @
const packageVersion = packageJson.version;

messages.push(
`${packageName} v${packageVersion}\n${getReleaseOutput(packageDir, "html_url")}`,
);
}

// group four messages per post to avoid post limits
const messageChunks = [];
for (let i = 0; i < messages.length; i += 4) {
messageChunks.push(messages.slice(i, i + 4).join("\n\n"));
}

for (const messageChunk of messageChunks) {
const message = `Just released:\n\n${messageChunk}`;

console.log(message);

exec(
`npx @humanwhocodes/crosspost -t -b -m ${JSON.stringify(message)}`,
{
stdio: "inherit",
env: process.env,
},
);
}

console.log("Posted to social media.");
}

//-----------------------------------------------------------------------------
// Main
//-----------------------------------------------------------------------------

const packageDirs = await getPackageDirs();
const dependencies = await calculatePackageDependencies(packageDirs);
const buildOrder = createBuildOrder(dependencies);
const packagesToPublish = getPackagesToPublish(buildOrder);

if (packagesToPublish.length === 0) {
console.log("No packages to publish.");
process.exit(0);
}

const npmPublishResults = publishPackagesToNpm(
packagesToPublish,
mapDependenciesToPaths(dependencies),
);
const jsrPublishResults = publishPackagesToJsr(packagesToPublish);

postResultToSocialMedia(npmPublishResults);

process.exitCode = [...npmPublishResults, ...jsrPublishResults].some(
([, value]) => !value.startsWith("ok"),
)
? 1
: 0;
Loading