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
121 changes: 119 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
name: publish

# This workflow owns both paths onto npm:
# This workflow owns every path onto npm:
#
# * A published GitHub Release tagged `vX.Y.Z` ships that version to the
# `latest` dist-tag. The tag is the source of truth for the version —
# nothing is committed back to the repository.
# * A merge into main ships a prerelease to the `next` dist-tag, versioned
# from the current `latest` plus the commit sha. `latest` is never touched
# by this path, so ordinary `npx plain-forge` users never see a prerelease.
# * A published GitHub Release tagged `regenerative-software-vX.Y.Z` ships
# the @plainlang/regenerative-software wrapper (packages/regenerative-software)
# at that version. It runs whichever plain-forge version its package.json pins.
#
# Auth is OIDC (npm trusted publishing) — no NPM_TOKEN secret. The trusted
# publisher is configured on npmjs.com against this repo and this exact
Expand Down Expand Up @@ -61,7 +64,7 @@ jobs:
publish:
name: Publish to npm
needs: test
if: ${{ github.event_name == 'release' }}
if: ${{ github.event_name == 'release' && !startsWith(github.event.release.tag_name, 'regenerative-software-v') }}
runs-on: ubuntu-latest
# Records the release under Deployments → npm, the same way scripts/deploy.sh
# does when publishing from a workstation.
Expand Down Expand Up @@ -295,6 +298,120 @@ jobs:
-H 'Content-type: application/json' \
--data "{\"text\":\"*plain-forge*: prerelease of \`${GITHUB_SHA:0:7}\` FAILED :x:. \`latest\` is unaffected. See <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|the run log>.\"}"

# ---------------------------------------------------------------------------
# Release tagged `regenerative-software-vX.Y.Z` -> @plainlang/regenerative-software.
#
# The wrapper is versioned independently of plain-forge: the tag sets the
# wrapper's version, and packages/regenerative-software/package.json pins the
# plain-forge version it runs. Lives in this file for the same trusted-publisher
# reason as the prerelease job.
publish-regenerative-software:
name: Publish @plainlang/regenerative-software to npm
needs: test
if: ${{ github.event_name == 'release' && startsWith(github.event.release.tag_name, 'regenerative-software-v') }}
runs-on: ubuntu-latest
environment:
name: npm
url: https://www.npmjs.com/package/@plainlang/regenerative-software
permissions:
contents: read
id-token: write # OIDC token for trusted publishing

env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
PKG: "@plainlang/regenerative-software"

defaults:
run:
working-directory: packages/regenerative-software

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.release.tag_name }}

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24 # trusted publishing needs >= 22.14

- name: Use an npm that supports trusted publishing
run: |
npm install -g npm@latest
npm --version

- name: Read the version from the release tag
id: pkg
env:
TAG: ${{ github.event.release.tag_name }}
run: |
VERSION="${TAG#regenerative-software-v}"
if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "::error::tag '$TAG' is not a semver release tag (expected regenerative-software-vX.Y.Z)"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "forge=$(jq -r '.dependencies["plain-forge"]' package.json)" >> "$GITHUB_OUTPUT"

- name: Refuse if this version is already on npm
run: |
if npm view "$PKG@${{ steps.pkg.outputs.version }}" version >/dev/null 2>&1; then
echo "::error::$PKG@${{ steps.pkg.outputs.version }} is already published — tag a new version"
exit 1
fi

- name: Refuse if the pinned plain-forge is not on npm
run: |
npm view "plain-forge@${{ steps.pkg.outputs.forge }}" version >/dev/null 2>&1 \
|| { echo "::error::package.json pins plain-forge@${{ steps.pkg.outputs.forge }}, which is not published"; exit 1; }

- run: npm ci

- name: Set the package version to match the tag
run: npm version "${{ steps.pkg.outputs.version }}" --no-git-tag-version --allow-same-version

# Source-directory runs miss packaging mistakes, so install the packed
# tarball into a clean project and run the real installer through it.
- name: Smoke-test the packed package
run: |
smoke="$RUNNER_TEMP/smoke project"
mkdir -p "$smoke"
npm pack --pack-destination "$RUNNER_TEMP"
cd "$smoke"
npm init -y >/dev/null
npm install --no-audit --no-fund "$RUNNER_TEMP"/plainlang-regenerative-software-*.tgz
npx --no "$PKG" install --agent claude --scope project --skip-plain-parser
test -f .claude/skills/forge-plain/SKILL.md
test -f .claude/.plain-forge/manifest.json

- name: Notify Slack (start)
if: ${{ env.SLACK_WEBHOOK_URL != '' }}
run: |
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-type: application/json' \
--data "{\"text\":\"*$PKG*: release \`v${{ steps.pkg.outputs.version }}\` published by *${{ github.actor }}* — shipping to npm...\"}"

- name: Publish
run: |
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
npm publish --access public --tag next
else
npm publish --access public
fi

- name: Notify Slack (success)
if: ${{ success() && env.SLACK_WEBHOOK_URL != '' }}
run: |
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-type: application/json' \
--data "{\"text\":\"*$PKG*: \`v${{ steps.pkg.outputs.version }}\` published to npm :white_check_mark: (runs plain-forge \`${{ steps.pkg.outputs.forge }}\`). Try it with \`npx $PKG@latest install\`.\"}"

- name: Notify Slack (failure)
if: ${{ failure() && env.SLACK_WEBHOOK_URL != '' }}
run: |
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-type: application/json' \
--data "{\"text\":\"*$PKG*: publishing \`${{ github.event.release.tag_name }}\` to npm FAILED :x:. See <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|the run log>.\"}"

# Release pipelines to be turned on only after the rest of the infrastructure
# is ready.
#
Expand Down
50 changes: 50 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,55 @@ Slack gets a line either way: the version published and how to try it, or the fa
`latest` confirmed unaffected. A re-run that had nothing to publish says so rather than
claiming something shipped. The run summary records the same thing.

## Releasing @plainlang/regenerative-software

[`packages/regenerative-software`](packages/regenerative-software) is a thin wrapper that
lets users run `npx @plainlang/regenerative-software install`. It has its own version and
its own tags, and runs the exact `plain-forge` version pinned in its `package.json`.

1. To ship a newer `plain-forge`, re-pin it on a branch and merge:

```bash
cd packages/regenerative-software
npm install --save-exact plain-forge@latest # updates package.json + package-lock.json
```

2. Create a release tagged `regenerative-software-vX.Y.Z` and keep **plain-forge** marked
as the latest GitHub release:

```bash
gh release create regenerative-software-v1.0.1 --target main \
--title "regenerative-software v1.0.1" --latest=false --notes-file notes.md
```

The `publish-regenerative-software` job in `publish.yml` handles these tags (the
plain-forge `publish` job skips them). It runs the tests and checks the tag is semver.
It refuses a version already on npm or a pinned `plain-forge` that isn't published. Then it
installs the packed tarball into a clean project, runs the real installer through it, and
publishes. A pre-release goes to `next`, as with plain-forge. Like plain-forge,
`package.json`'s `version` is stale by design and the tag decides.

`--latest=false` matters: `scripts/deploy.sh` publishes "the latest GitHub release" by
default, and it refuses a `regenerative-software-v*` tag as non-semver.

**One-time setup, before the first automated release.** The `@plainlang` scope must exist as an npm organization ([create it](https://www.npmjs.com/org/create); unlimited public packages are free), and you must be a member. npm only lets you configure a
trusted publisher on a package that already exists. Publish `1.0.0` once by hand, with
an account that can publish to the `@plainlang` scope:

```bash
npm login
./scripts/deploy-regenerative-software.sh --dry-run # test + smoke test, publish nothing
./scripts/deploy-regenerative-software.sh # publish package.json's version
./scripts/deploy-regenerative-software.sh --version 1.0.1 --otp 123456
```

The script publishes `packages/regenerative-software` as it is in your working tree. It
copies the package to a temp dir, runs the wrapper tests and checks the version isn't on
npm yet. It installs the packed tarball into a clean project, runs the real installer
through it, and publishes that same tarball. Then add a trusted publisher on npmjs.com:
`@plainlang/regenerative-software` → Settings → Trusted Publisher → GitHub Actions,
`plainlang/plain-forge`, workflow `publish.yml`, environment `npm`.

## Release notes

Generated notes (`--generate-notes`, or the button on the form) list merged PRs and are
Expand Down Expand Up @@ -161,6 +210,7 @@ at the repo root — gitignored, and the only place a webhook should live locall
| what | where | note |
|---|---|---|
| Trusted publisher | npmjs.com → `plain-forge` → Settings → Trusted Publisher | GitHub Actions, `plainlang/plain-forge`, workflow `publish.yml` |
| Trusted publisher | npmjs.com → `@plainlang/regenerative-software` → Settings → Trusted Publisher | same as above; **not yet configured**, see [Releasing @plainlang/regenerative-software](#releasing-plainlangregenerative-software) |
| `SLACK_WEBHOOK_URL` | repo secret | notifications are skipped without it |
| `LINEAR_ACCESS_KEY` | repo secret | records the release in Linear; the job is currently commented out in `publish.yml` |
| Branch protection | `main` | `Test (node 22 / 24)` required, no force pushes, no deletion |
Expand Down
32 changes: 32 additions & 0 deletions packages/regenerative-software/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# @plainlang/regenerative-software

Runs [plain-forge](https://github.com/plainlang/plain-forge) under the `@plainlang` name:

```bash
npx @plainlang/regenerative-software install
npx @plainlang/regenerative-software install --agent codex --scope project
npx @plainlang/regenerative-software update
npx @plainlang/regenerative-software uninstall
```

Every argument is forwarded to `plain-forge`, so prompts, output and exit codes are the
same as `npx plain-forge ...`. Requires Node.js 22 or newer.

## How it works

`plain-forge` is an exact-pinned dependency. `cli.cjs` resolves its `bin/cli.mjs` and
runs it with the same Node binary, no shell, inherited stdio, and SIGINT/SIGTERM
forwarded to the child. Each wrapper release runs exactly one `plain-forge` version.
`@latest` picks the newest wrapper, which then runs the version it pins.

## Releasing

Releases are automated. Re-pin `plain-forge` with
`npm install --save-exact plain-forge@latest`, merge, then publish a GitHub Release
tagged `regenerative-software-vX.Y.Z`. CI tests and smoke-tests the packed tarball,
then publishes. See
[RELEASING.md](../../RELEASING.md#releasing-plainlangregenerative-software).

If `plain-forge` ever moves `bin/cli.mjs` or adds an `exports` map, update the
`require.resolve` path in `cli.cjs`. Never add a `postinstall` script that runs the
installer; it must run only when the user invokes the command.
73 changes: 73 additions & 0 deletions packages/regenerative-software/cli.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env node
"use strict";

// Runs the pinned plain-forge dependency's CLI with every argument forwarded, so
// `npx @plainlang/regenerative-software install` behaves like `npx plain-forge install`.

const { spawn } = require("node:child_process");

if (Number(process.versions.node.split(".")[0]) < 22) {
console.error("regenerative-software requires Node.js 22 or newer.");
process.exit(1);
}

let cliPath;

try {
cliPath = require.resolve("plain-forge/bin/cli.mjs");
} catch (error) {
console.error(`Unable to locate the plain-forge CLI: ${error.message}`);
process.exit(1);
}

// Launch Node directly (no shell) so arguments reach plain-forge unquoted and
// Windows .cmd shims are never involved.
const child = spawn(process.execPath, [cliPath, ...process.argv.slice(2)], {
stdio: "inherit",
shell: false,
});

let processError = false;
const handlers = new Map();

// Forward termination requests sent only to the wrapper (CI cancellation, process
// supervisors); otherwise the wrapper would exit and leave the installer running.
for (const signal of ["SIGINT", "SIGTERM"]) {
const handler = () => {
if (child.exitCode === null && child.signalCode === null) {
child.kill(signal);
}
};

handlers.set(signal, handler);
process.on(signal, handler);
}

child.on("error", (error) => {
processError = true;
console.error(`Unable to run plain-forge: ${error.message}`);
});

child.once("close", (code, signal) => {
for (const [name, handler] of handlers) {
process.removeListener(name, handler);
}

if (processError) {
process.exitCode = 1;
return;
}

if (signal) {
process.exitCode = signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1;

// Re-raise so our parent sees a signal death, as it would for plain-forge itself.
if (process.platform !== "win32") {
process.kill(process.pid, signal);
}

return;
}

process.exitCode = code ?? 1;
});
34 changes: 34 additions & 0 deletions packages/regenerative-software/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions packages/regenerative-software/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@plainlang/regenerative-software",
"version": "1.0.0",
"description": "Run plain-forge through @plainlang/regenerative-software",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/plainlang/plain-forge.git",
"directory": "packages/regenerative-software"
},
"homepage": "https://github.com/plainlang/plain-forge",
"bin": {
"regenerative-software": "./cli.cjs"
},
"files": [
"cli.cjs"
],
"engines": {
"node": ">=22"
},
"scripts": {
"check": "node --check cli.cjs",
"prepack": "npm run check"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"plain-forge": "1.0.23"
}
}
Loading
Loading